diff --git a/account.go b/account.go index 180e9b0..c41f24d 100644 --- a/account.go +++ b/account.go @@ -1,1070 +1,1099 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "encoding/json" "fmt" "html/template" "net/http" "regexp" "strings" "sync" "time" "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/guregu/null/zero" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) type ( userSettings struct { Username string `schema:"username" json:"username"` Email string `schema:"email" json:"email"` NewPass string `schema:"new-pass" json:"new_pass"` OldPass string `schema:"current-pass" json:"current_pass"` IsLogOut bool `schema:"logout" json:"logout"` } UserPage struct { page.StaticPage PageTitle string Separator template.HTML IsAdmin bool CanInvite bool } ) func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage { up := &UserPage{ StaticPage: pageForReq(app, r), PageTitle: title, } up.Username = u.Username up.Flashes = flashes up.Path = r.URL.Path up.IsAdmin = u.IsAdmin() up.CanInvite = canUserInvite(app.cfg, up.IsAdmin) return up } func canUserInvite(cfg *config.Config, isAdmin bool) bool { return cfg.App.UserInvites != "" && (isAdmin || cfg.App.UserInvites != "admin") } func (up *UserPage) SetMessaging(u *User) { //up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) } const ( loginAttemptExpiration = 3 * time.Second ) var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead") func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { _, err := signup(app, w, r) return err } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { reqJSON := IsJSON(r) // Get params var ur userRegistration if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&ur) if err != nil { log.Error("Couldn't parse signup JSON request: %v\n", err) return nil, ErrBadJSON } } else { // Check if user is already logged in u := getUserSession(app, r) if u != nil { return &AuthUser{User: u}, nil } err := r.ParseForm() if err != nil { log.Error("Couldn't parse signup form request: %v\n", err) return nil, ErrBadFormData } err = app.formDecoder.Decode(&ur, r.PostForm) if err != nil { log.Error("Couldn't decode signup form request: %v\n", err) return nil, ErrBadFormData } } return signupWithRegistration(app, ur, w, r) } func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { reqJSON := IsJSON(r) // Validate required params (alias) if signup.Alias == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."} } if signup.Pass == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."} } var desiredUsername string if signup.Normalize { // With this option we simply conform the username to what we expect // without complaining. Since they might've done something funny, like // enter: write.as/Way Out There, we'll use their raw input for the new // collection name and sanitize for the slug / username. desiredUsername = signup.Alias signup.Alias = getSlug(signup.Alias, "") } if !author.IsValidUsername(app.cfg, signup.Alias) { // Ensure the username is syntactically correct. return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."} } // Handle empty optional params // TODO: remove this var createdWithPass := true hashedPass, err := auth.HashPass([]byte(signup.Pass)) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Create struct to insert u := &User{ Username: signup.Alias, HashedPass: hashedPass, HasPass: createdWithPass, Email: zero.NewString("", signup.Email != ""), Created: time.Now().Truncate(time.Second).UTC(), } if signup.Email != "" { encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email) if err != nil { log.Error("Unable to encrypt email: %s\n", err) } else { u.Email.String = string(encEmail) } } // Create actual user if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil { return nil, err } // Log invite if needed if signup.InviteCode != "" { cu, err := app.db.GetUserForAuth(signup.Alias) if err != nil { return nil, err } err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID) if err != nil { return nil, err } } // Add back unencrypted data for response if signup.Email != "" { u.Email.String = signup.Email } resUser := &AuthUser{ User: u, } if !createdWithPass { resUser.Password = signup.Pass } title := signup.Alias if signup.Normalize { title = desiredUsername } resUser.Collections = &[]Collection{ { Alias: signup.Alias, Title: title, }, } var token string if reqJSON && !signup.Web { token, err = app.db.GetAccessToken(u.ID) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser.AccessToken = token } else { session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 log.Error("Session: %v; ignoring", err) } session.Values[cookieUserVal] = resUser.User.Cookie() err = session.Save(r, w) if err != nil { log.Error("Couldn't save session: %v", err) return nil, err } } if reqJSON { return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated) } return resUser, nil } func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error { session, err := app.sessionStore.Get(r, cookieName) if err != nil { return ErrInternalCookieSession } // Ensure user has an email or password set before they go, so they don't // lose access to their account. val := session.Values[cookieUserVal] var u = &User{} var ok bool if u, ok = val.(*User); !ok { log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values) err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } u, err = app.db.GetUserByID(u.ID) if err != nil && err != ErrUserNotFound { return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."} } session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } t := auth.GetToken(accessToken) if len(t) == 0 { return ErrNoAccessToken } err := app.db.DeleteToken(t) if err != nil { return err } return impart.HTTPError{Status: http.StatusNoContent} } func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { var earlyError string oneTimeToken := r.FormValue("with") if oneTimeToken != "" { log.Info("Calling login with one-time token.") err := login(app, w, r) if err != nil { log.Info("Received error: %v", err) earlyError = fmt.Sprintf("%s", err) } } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session; ignoring: %v", err) } p := &struct { page.StaticPage To string Message template.HTML Flashes []template.HTML LoginUsername string }{ pageForReq(app, r), r.FormValue("to"), template.HTML(""), []template.HTML{}, getTempInfo(app, "login-user", r, w), } if earlyError != "" { p.Flashes = append(p.Flashes, template.HTML(earlyError)) } // Display any error messages flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } err = pages["login.tmpl"].ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render login: %v", err) return err } return nil } func webLogin(app *App, w http.ResponseWriter, r *http.Request) error { err := login(app, w, r) if err != nil { username := r.FormValue("alias") // Login request was unsuccessful; save the error in the session and redirect them if err, ok := err.(impart.HTTPError); ok { session, _ := app.sessionStore.Get(r, cookieName) if session != nil { session.AddFlash(err.Message) session.Save(r, w) } if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 { // Retain fixed username recommendation for the login form username = m[1] } } // Pass along certain information saveTempInfo(app, "login-user", username, r, w) // Retain post-login URL if one was given redirectTo := "/login" postLoginRedirect := r.FormValue("to") if postLoginRedirect != "" { redirectTo += "?to=" + postLoginRedirect } log.Error("Unable to login: %v", err) return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo} } return nil } var loginAttemptUsers = sync.Map{} func login(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) oneTimeToken := r.FormValue("with") verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "") redirectTo := r.FormValue("to") if redirectTo == "" { if app.cfg.App.SingleUser { redirectTo = "/me/new" } else { redirectTo = "/" } } var u *User var err error var signin userCredentials // Log in with one-time token if one is given if oneTimeToken != "" { log.Info("Login: Logging user in via token.") userID := app.db.GetUserID(oneTimeToken) if userID == -1 { log.Error("Login: Got user -1 from token") err := ErrBadAccessToken err.Message = "Expired or invalid login code." return err } log.Info("Login: Found user %d.", userID) u, err = app.db.GetUserByID(userID) if err != nil { log.Error("Unable to fetch user on one-time token login: %v", err) return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."} } log.Info("Login: Got user via token") } else { // Get params if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&signin) if err != nil { log.Error("Couldn't parse signin JSON request: %v\n", err) return ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse signin form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&signin, r.PostForm) if err != nil { log.Error("Couldn't decode signin form request: %v\n", err) return ErrBadFormData } } log.Info("Login: Attempting login for '%s'", signin.Alias) // Validate required params (all) if signin.Alias == "" { msg := "Parameter `alias` required." if signin.Web { msg = "A username is required." } return impart.HTTPError{http.StatusBadRequest, msg} } if !signin.EmailLogin && signin.Pass == "" { msg := "Parameter `pass` required." if signin.Web { msg = "A password is required." } return impart.HTTPError{http.StatusBadRequest, msg} } // Prevent excessive login attempts on the same account // Skip this check in dev environment if !app.cfg.Server.Dev { now := time.Now() attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration)) if att { if attemptExpTime, ok := attemptExp.(time.Time); ok { if attemptExpTime.After(now) { // This user attempted previously, and the period hasn't expired yet return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."} } else { // This user attempted previously, but the time expired; free up space loginAttemptUsers.Delete(signin.Alias) } } else { log.Error("Unable to cast expiration to time") } } } // Retrieve password u, err = app.db.GetUserForAuth(signin.Alias) if err != nil { log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err) if strings.IndexAny(signin.Alias, "@") > 0 { log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message) return ErrUserNotFoundEmail } return err } // Authenticate if u.Email.String == "" { // User has no email set, so check if they haven't added a password, either, // so we can return a more helpful error message. if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass { log.Info("Tried logging in to %s, but no password or email.", signin.Alias) return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."} } } if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } } if reqJSON && !signin.Web { var token string if r.Header.Get("User-Agent") == "" { // Get last created token when User-Agent is empty token = app.db.FetchLastAccessToken(u.ID) if token == "" { token, err = app.db.GetAccessToken(u.ID) } } else { token, err = app.db.GetAccessToken(u.ID) } if err != nil { log.Error("Login: Unable to create access token: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser := getVerboseAuthUser(app, token, u, verbose) return impart.WriteSuccess(w, resUser, http.StatusOK) } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Login: Session: %v; ignoring", err) } // Remove unwanted data session.Values[cookieUserVal] = u.Cookie() err = session.Save(r, w) if err != nil { log.Error("Login: Couldn't save session: %v", err) // TODO: return error } // Send success if reqJSON { return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK) } log.Info("Login: Redirecting to %s", redirectTo) w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser { resUser := &AuthUser{ AccessToken: token, User: u, } // Fetch verbose user data if requested if verbose { posts, err := app.db.GetUserPosts(u) if err != nil { log.Error("Login: Unable to get user posts: %v", err) } colls, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("Login: Unable to get user collections: %v", err) } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { // TODO: correct error meesage log.Error("Login: Unable to get user collections: %v", err) } resUser.Posts = posts resUser.Collections = colls resUser.User.HasPass = passIsSet } return resUser } func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error { // Fetch extra user data p := NewUserPage(app, r, u, "Export", nil) showUserPage(w, "export", p) return nil } func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var filename string var u = &User{} reqJSON := IsJSON(r) if reqJSON { // Use given Authorization header accessToken := r.Header.Get("Authorization") if accessToken == "" { return nil, filename, ErrNoAccessToken } userID := app.db.GetUserID(accessToken) if userID == -1 { return nil, filename, ErrBadAccessToken } var err error u, err = app.db.GetUserByID(userID) if err != nil { return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."} } } else { // Use user cookie session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Session: %v; ignoring", err) } val := session.Values[cookieUserVal] var ok bool if u, ok = val.(*User); !ok { return nil, filename, ErrNotLoggedIn } } filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") // Fetch data we're exporting var err error var data []byte posts, err := app.db.GetUserPosts(u) if err != nil { return data, filename, err } // Export as CSV if strings.HasSuffix(r.URL.Path, ".csv") { data = exportPostsCSV(app.cfg.App.Host, u, posts) return data, filename, err } if strings.HasSuffix(r.URL.Path, ".zip") { data = exportPostsZip(u, posts) return data, filename, err } if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(posts, "", "\t") } else { data, err = json.Marshal(posts) } return data, filename, err } func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var err error filename := "" u := getUserSession(app, r) if u == nil { return nil, filename, ErrNotLoggedIn } filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") exportUser := compileFullExport(app, u) var data []byte if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(exportUser, "", "\t") } else { data, err = json.Marshal(exportUser) } return data, filename, err } func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) uObj := struct { ID int64 `json:"id,omitempty"` Username string `json:"username,omitempty"` }{} var err error if reqJSON { _, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization")) if err != nil { return err } } else { u := getUserSession(app, r) if u == nil { return impart.WriteSuccess(w, uObj, http.StatusOK) } uObj.Username = u.Username } return impart.WriteSuccess(w, uObj, http.StatusOK) } func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } var err error p := GetPostsCache(u.ID) if p == nil { userPostsCache.Lock() if userPostsCache.users[u.ID].ready == nil { userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})} userPostsCache.Unlock() p, err = app.db.GetUserPosts(u) if err != nil { return err } CachePosts(u.ID, p) } else { userPostsCache.Unlock() <-userPostsCache.users[u.ID].ready p = GetPostsCache(u.ID) } } return impart.WriteSuccess(w, p, http.StatusOK) } func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } p, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p, err := app.db.GetAnonymousPosts(u) if err != nil { log.Error("unable to fetch anon posts: %v", err) } // nil-out AnonymousPosts slice for easy detection in the template if p != nil && len(*p) == 0 { p = nil } f, err := getSessionFlashes(app, w, r, nil) if err != nil { log.Error("unable to fetch flashes: %v", err) } c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view articles: %v", err) + } d := struct { *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection + Suspended bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, + Suspended: suspended, } d.UserPage.SetMessaging(u) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT") showUserPage(w, "articles", d) return nil } func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error { c, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) return fmt.Errorf("No collections") } f, _ := getSessionFlashes(app, w, r, nil) uc, _ := app.db.GetUserCollectionCount(u.ID) // TODO: handle any errors + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view collections %v", err) + return fmt.Errorf("view collections: %v", err) + } d := struct { *UserPage Collections *[]Collection UsedCollections, TotalCollections int NewBlogsDisabled bool + Suspended bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), + Suspended: suspended, } d.UserPage.SetMessaging(u) showUserPage(w, "collections", d) return nil } func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) c, err := app.db.GetCollection(vars["collection"]) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view edit collection %v", err) + return fmt.Errorf("view edit collection: %v", err) + } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage *Collection + Suspended bool }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, + Suspended: suspended, } showUserPage(w, "collection", obj) return nil } func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) var s userSettings var u *User var sess *sessions.Session var err error if reqJSON { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } u, err = app.db.GetAPIUser(accessToken) if err != nil { return ErrBadAccessToken } decoder := json.NewDecoder(r.Body) err := decoder.Decode(&s) if err != nil { log.Error("Couldn't parse settings JSON request: %v\n", err) return ErrBadJSON } // Prevent all username updates // TODO: support changing username via JSON API request s.Username = "" } else { u, sess = getUserAndSession(app, r) if u == nil { return ErrNotLoggedIn } err := r.ParseForm() if err != nil { log.Error("Couldn't parse settings form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&s, r.PostForm) if err != nil { log.Error("Couldn't decode settings form request: %v\n", err) return ErrBadFormData } } // Do update postUpdateReturn := r.FormValue("return") redirectTo := "/me/settings" if s.IsLogOut { redirectTo += "?logout=1" } else if postUpdateReturn != "" { redirectTo = postUpdateReturn } // Only do updates on values we need if s.Username != "" && s.Username == u.Username { // Username hasn't actually changed; blank it out s.Username = "" } err = app.db.ChangeSettings(app, u, &s) if err != nil { if reqJSON { return err } if err, ok := err.(impart.HTTPError); ok { addSessionFlash(app, w, r, err.Message, nil) } } else { // Successful update. if reqJSON { return impart.WriteSuccess(w, u, http.StatusOK) } if s.IsLogOut { redirectTo = "/me/logout" } else { sess.Values[cookieUserVal] = u.Cookie() addSessionFlash(app, w, r, "Account updated.", nil) } } w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } curPass := r.FormValue("current") newPass := r.FormValue("new") // Ensure a new password is given (always required) if newPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide a new password."} } userID, sudo := app.db.GetUserIDPrivilege(accessToken) if userID == -1 { return ErrBadAccessToken } // Ensure a current password is given if the access token doesn't have sudo // privileges. if !sudo && curPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide current password."} } // Hash the new password hashedPass, err := auth.HashPass([]byte(newPass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Do update err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass) if err != nil { return err } return impart.WriteSuccess(w, struct{}{}, http.StatusOK) } func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error { var c *Collection var err error vars := mux.Vars(r) alias := vars["collection"] if alias != "" { c, err = app.db.GetCollection(alias) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } } topPosts, err := app.db.GetTopPosts(u, alias) if err != nil { log.Error("Unable to get top posts: %v", err) return err } flashes, _ := getSessionFlashes(app, w, r, nil) titleStats := "" if c != nil { titleStats = c.DisplayTitle() + " " } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("view stats: %v", err) + return err + } obj := struct { *UserPage VisitsBlog string Collection *Collection TopPosts *[]PublicPost APFollowers int + Suspended bool }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, + Suspended: suspended, } if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) if err != nil { return err } obj.APFollowers = len(*folls) } showUserPage(w, "stats", obj) return nil } func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error { fullUser, err := app.db.GetUserByID(u.ID) if err != nil { log.Error("Unable to get user for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { log.Error("Unable to get isUserPassSet for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage - Email string - HasPass bool - IsLogOut bool + Email string + HasPass bool + IsLogOut bool + Suspended bool }{ - UserPage: NewUserPage(app, r, u, "Account Settings", flashes), - Email: fullUser.EmailClear(app.keys), - HasPass: passIsSet, - IsLogOut: r.FormValue("logout") == "1", + UserPage: NewUserPage(app, r, u, "Account Settings", flashes), + Email: fullUser.EmailClear(app.keys), + HasPass: passIsSet, + IsLogOut: r.FormValue("logout") == "1", + Suspended: fullUser.IsSilenced(), } showUserPage(w, "settings", obj) return nil } func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error { session, err := app.sessionStore.Get(r, "t") if err != nil { return ErrInternalCookieSession } session.Values[key] = val err = session.Save(r, w) if err != nil { log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err) } return err } func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string { session, err := app.sessionStore.Get(r, "t") if err != nil { return "" } // Get the information var s = "" var ok bool if s, ok = session.Values[key].(string); !ok { return "" } // Delete cookie session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't erase temp data for key %s: %v", key, err) } // Return value return s } diff --git a/activitypub.go b/activitypub.go index 80d484e..a18a636 100644 --- a/activitypub.go +++ b/activitypub.go @@ -1,705 +1,745 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "bytes" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "fmt" "io/ioutil" "net/http" "net/http/httputil" "net/url" "strconv" "time" "github.com/gorilla/mux" "github.com/writeas/activity/streams" "github.com/writeas/httpsig" "github.com/writeas/impart" "github.com/writeas/nerds/store" "github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/log" ) const ( // TODO: delete. don't use this! apCustomHandleDefault = "blog" ) type RemoteUser struct { ID int64 ActorID string Inbox string SharedInbox string } func (ru *RemoteUser) AsPerson() *activitystreams.Person { return &activitystreams.Person{ BaseObject: activitystreams.BaseObject{ Type: "Person", Context: []interface{}{ activitystreams.Namespace, }, ID: ru.ActorID, }, Inbox: ru.Inbox, Endpoints: activitystreams.Endpoints{ SharedInbox: ru.SharedInbox, }, } } func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] // TODO: enforce visibility // Get base Collection data var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection activities: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host p := c.PersonObject() return impart.RenderActivityJSON(w, p, http.StatusOK) } func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] // TODO: enforce visibility // Get base Collection data var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection outbox: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if app.cfg.App.SingleUser { if alias != c.Alias { return ErrCollectionNotFound } } res := &CollectionObj{Collection: *c} app.db.GetPostsCount(res, false) accountRoot := c.FederatedAccount() page := r.FormValue("page") p, err := strconv.Atoi(page) if err != nil || p < 1 { // Return outbox oc := activitystreams.NewOrderedCollection(accountRoot, "outbox", res.TotalPosts) return impart.RenderActivityJSON(w, oc, http.StatusOK) } // Return outbox page ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p) ocp.OrderedItems = []interface{}{} posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false) for _, pp := range *posts { pp.Collection = res o := pp.ActivityObject(app.cfg) a := activitystreams.NewCreateActivity(o) ocp.OrderedItems = append(ocp.OrderedItems, *a) } return impart.RenderActivityJSON(w, ocp, http.StatusOK) } func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] // TODO: enforce visibility // Get base Collection data var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection followers: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() folls, err := app.db.GetAPFollowers(c) if err != nil { return err } page := r.FormValue("page") p, err := strconv.Atoi(page) if err != nil || p < 1 { // Return outbox oc := activitystreams.NewOrderedCollection(accountRoot, "followers", len(*folls)) return impart.RenderActivityJSON(w, oc, http.StatusOK) } // Return outbox page ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "followers", len(*folls), p) ocp.OrderedItems = []interface{}{} /* for _, f := range *folls { ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID) } */ return impart.RenderActivityJSON(w, ocp, http.StatusOK) } func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] // TODO: enforce visibility // Get base Collection data var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection following: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host accountRoot := c.FederatedAccount() page := r.FormValue("page") p, err := strconv.Atoi(page) if err != nil || p < 1 { // Return outbox oc := activitystreams.NewOrderedCollection(accountRoot, "following", 0) return impart.RenderActivityJSON(w, oc, http.StatusOK) } // Return outbox page ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p) ocp.OrderedItems = []interface{}{} return impart.RenderActivityJSON(w, ocp, http.StatusOK) } func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { // TODO: return Reject? return err } + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("fetch collection inbox: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if debugging { dump, err := httputil.DumpRequest(r, true) if err != nil { log.Error("Can't dump: %v", err) } else { log.Info("Rec'd! %q", dump) } } var m map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&m); err != nil { return err } a := streams.NewAccept() p := c.PersonObject() var to *url.URL var isFollow, isUnfollow bool fullActor := &activitystreams.Person{} var remoteUser *RemoteUser res := &streams.Resolver{ FollowCallback: func(f *streams.Follow) error { isFollow = true // 1) Use the Follow concrete type here // 2) Errors are propagated to res.Deserialize call below m["@context"] = []string{activitystreams.Namespace} b, _ := json.Marshal(m) if debugging { log.Info("Follow: %s", b) } _, followID := f.GetId() if followID == nil { log.Error("Didn't resolve follow ID") } else { aID := c.FederatedAccount() + "#accept-" + store.GenerateFriendlyRandomString(20) acceptID, err := url.Parse(aID) if err != nil { log.Error("Couldn't parse generated Accept URL '%s': %v", aID, err) } a.SetId(acceptID) } a.AppendObject(f.Raw()) _, to = f.GetActor(0) obj := f.Raw().GetObjectIRI(0) a.AppendActor(obj) // First get actor information if to == nil { return fmt.Errorf("No valid `to` string") } fullActor, remoteUser, err = getActor(app, to.String()) if err != nil { return err } return impart.RenderActivityJSON(w, m, http.StatusOK) }, UndoCallback: func(u *streams.Undo) error { isUnfollow = true m["@context"] = []string{activitystreams.Namespace} b, _ := json.Marshal(m) if debugging { log.Info("Undo: %s", b) } a.AppendObject(u.Raw()) _, to = u.GetActor(0) // TODO: get actor from object.object, not object obj := u.Raw().GetObjectIRI(0) a.AppendActor(obj) if to != nil { // Populate fullActor from DB? remoteUser, err = getRemoteUser(app, to.String()) if err != nil { if iErr, ok := err.(*impart.HTTPError); ok { if iErr.Status == http.StatusNotFound { log.Error("No remoteuser info for Undo event!") } } return err } else { fullActor = remoteUser.AsPerson() } } else { log.Error("No to on Undo!") } return impart.RenderActivityJSON(w, m, http.StatusOK) }, } if err := res.Deserialize(m); err != nil { // 3) Any errors from #2 can be handled, or the payload is an unknown type. log.Error("Unable to resolve Follow: %v", err) if debugging { log.Error("Map: %s", m) } return err } go func() { time.Sleep(2 * time.Second) am, err := a.Serialize() if err != nil { log.Error("Unable to serialize Accept: %v", err) return } am["@context"] = []string{activitystreams.Namespace} if to == nil { log.Error("No to! %v", err) return } err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am) if err != nil { log.Error("Unable to make activity POST: %v", err) return } if isFollow { t, err := app.db.Begin() if err != nil { log.Error("Unable to start transaction: %v", err) return } var followerID int64 if remoteUser != nil { followerID = remoteUser.ID } else { // Add follower locally, since it wasn't found before res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox) if err != nil { // if duplicate key, res will be nil and panic on // res.LastInsertId below t.Rollback() log.Error("Couldn't add new remoteuser in DB: %v\n", err) return } followerID, err = res.LastInsertId() if err != nil { t.Rollback() log.Error("no lastinsertid for followers, rolling back: %v", err) return } // Add in key _, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, followerID, fullActor.PublicKey.PublicKeyPEM) if err != nil { if !app.db.isDuplicateKeyErr(err) { t.Rollback() log.Error("Couldn't add follower keys in DB: %v\n", err) return } } } // Add follow _, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", c.ID, followerID) if err != nil { if !app.db.isDuplicateKeyErr(err) { t.Rollback() log.Error("Couldn't add follower in DB: %v\n", err) return } } err = t.Commit() if err != nil { t.Rollback() log.Error("Rolling back after Commit(): %v\n", err) return } } else if isUnfollow { // Remove follower locally _, err = app.db.Exec("DELETE FROM remotefollows WHERE collection_id = ? AND remote_user_id = (SELECT id FROM remoteusers WHERE actor_id = ?)", c.ID, to.String()) if err != nil { log.Error("Couldn't remove follower from DB: %v\n", err) } } }() return nil } func makeActivityPost(hostName string, p *activitystreams.Person, url string, m interface{}) error { log.Info("POST %s", url) b, err := json.Marshal(m) if err != nil { return err } r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b)) r.Header.Add("Content-Type", "application/activity+json") r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")") h := sha256.New() h.Write(b) r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) // Sign using the 'Signature' header privKey, err := activitypub.DecodePrivateKey(p.GetPrivKey()) if err != nil { return err } signer := httpsig.NewSigner(p.PublicKey.ID, privKey, httpsig.RSASHA256, []string{"(request-target)", "date", "host", "digest"}) err = signer.SignSigHeader(r) if err != nil { log.Error("Can't sign: %v", err) } if debugging { dump, err := httputil.DumpRequestOut(r, true) if err != nil { log.Error("Can't dump: %v", err) } else { log.Info("%s", dump) } } resp, err := http.DefaultClient.Do(r) if err != nil { return err } if resp != nil && resp.Body != nil { defer resp.Body.Close() } body, err := ioutil.ReadAll(resp.Body) if err != nil { return err } if debugging { log.Info("Status : %s", resp.Status) log.Info("Response: %s", body) } return nil } func resolveIRI(hostName, url string) ([]byte, error) { log.Info("GET %s", url) r, _ := http.NewRequest("GET", url, nil) r.Header.Add("Accept", "application/activity+json") r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")") if debugging { dump, err := httputil.DumpRequestOut(r, true) if err != nil { log.Error("Can't dump: %v", err) } else { log.Info("%s", dump) } } resp, err := http.DefaultClient.Do(r) if err != nil { return nil, err } if resp != nil && resp.Body != nil { defer resp.Body.Close() } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } if debugging { log.Info("Status : %s", resp.Status) log.Info("Response: %s", body) } return body, nil } func deleteFederatedPost(app *App, p *PublicPost, collID int64) error { if debugging { log.Info("Deleting federated post!") } p.Collection.hostName = app.cfg.App.Host actor := p.Collection.PersonObject(collID) na := p.ActivityObject(app.cfg) // Add followers p.Collection.ID = collID followers, err := app.db.GetAPFollowers(&p.Collection.Collection) if err != nil { log.Error("Couldn't delete post (get followers)! %v", err) return err } inboxes := map[string][]string{} for _, f := range *followers { inbox := f.SharedInbox if inbox == "" { inbox = f.Inbox } if _, ok := inboxes[inbox]; ok { inboxes[inbox] = append(inboxes[inbox], f.ActorID) } else { inboxes[inbox] = []string{f.ActorID} } } for si, instFolls := range inboxes { na.CC = []string{} for _, f := range instFolls { na.CC = append(na.CC, f) } err = makeActivityPost(app.cfg.App.Host, actor, si, activitystreams.NewDeleteActivity(na)) if err != nil { log.Error("Couldn't delete post! %v", err) } } return nil } func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { if debugging { if isUpdate { log.Info("Federating updated post!") } else { log.Info("Federating new post!") } } actor := p.Collection.PersonObject(collID) na := p.ActivityObject(app.cfg) // Add followers p.Collection.ID = collID followers, err := app.db.GetAPFollowers(&p.Collection.Collection) if err != nil { log.Error("Couldn't post! %v", err) return err } log.Info("Followers for %d: %+v", collID, followers) inboxes := map[string][]string{} for _, f := range *followers { inbox := f.SharedInbox if inbox == "" { inbox = f.Inbox } if _, ok := inboxes[inbox]; ok { inboxes[inbox] = append(inboxes[inbox], f.ActorID) } else { inboxes[inbox] = []string{f.ActorID} } } for si, instFolls := range inboxes { na.CC = []string{} for _, f := range instFolls { na.CC = append(na.CC, f) } var activity *activitystreams.Activity if isUpdate { activity = activitystreams.NewUpdateActivity(na) } else { activity = activitystreams.NewCreateActivity(na) activity.To = na.To activity.CC = na.CC } err = makeActivityPost(app.cfg.App.Host, actor, si, activity) if err != nil { log.Error("Couldn't post! %v", err) } } return nil } func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { u := RemoteUser{ActorID: actorID} err := app.db.QueryRow("SELECT id, inbox, shared_inbox FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} case err != nil: log.Error("Couldn't get remote user %s: %v", actorID, err) return nil, err } return &u, nil } func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser, error) { log.Info("Fetching actor %s locally", actorIRI) actor := &activitystreams.Person{} remoteUser, err := getRemoteUser(app, actorIRI) if err != nil { if iErr, ok := err.(impart.HTTPError); ok { if iErr.Status == http.StatusNotFound { // Fetch remote actor log.Info("Not found; fetching actor %s remotely", actorIRI) actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI) if err != nil { log.Error("Unable to get actor! %v", err) return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actor."} } if err := unmarshalActor(actorResp, actor); err != nil { log.Error("Unable to unmarshal actor! %v", err) return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actor."} } } else { return nil, nil, err } } else { return nil, nil, err } } else { actor = remoteUser.AsPerson() } return actor, remoteUser, nil } // unmarshal actor normalizes the actor response to conform to // the type Person from github.com/writeas/web-core/activitysteams // // some implementations return different context field types // this converts any non-slice contexts into a slice func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error { // FIXME: Hubzilla has an object for the Actor's url: cannot unmarshal object into Go struct field Person.url of type string // flexActor overrides the Context field to allow // all valid representations during unmarshal flexActor := struct { activitystreams.Person Context json.RawMessage `json:"@context,omitempty"` }{} if err := json.Unmarshal(actorResp, &flexActor); err != nil { return err } actor.Endpoints = flexActor.Endpoints actor.Followers = flexActor.Followers actor.Following = flexActor.Following actor.ID = flexActor.ID actor.Icon = flexActor.Icon actor.Inbox = flexActor.Inbox actor.Name = flexActor.Name actor.Outbox = flexActor.Outbox actor.PreferredUsername = flexActor.PreferredUsername actor.PublicKey = flexActor.PublicKey actor.Summary = flexActor.Summary actor.Type = flexActor.Type actor.URL = flexActor.URL func(val interface{}) { switch val.(type) { case []interface{}: // already a slice, do nothing actor.Context = val.([]interface{}) default: actor.Context = []interface{}{val} } }(flexActor.Context) return nil } diff --git a/admin.go b/admin.go index 3b05bd4..ebb4225 100644 --- a/admin.go +++ b/admin.go @@ -1,501 +1,526 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "fmt" "net/http" "runtime" "strconv" "strings" "time" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" "github.com/writeas/web-core/passgen" "github.com/writeas/writefreely/appstats" "github.com/writeas/writefreely/config" ) var ( appStartTime = time.Now() sysStatus systemStatus ) const adminUsersPerPage = 30 type systemStatus struct { Uptime string NumGoroutine int // General statistics. MemAllocated string // bytes allocated and still in use MemTotal string // bytes allocated (even if freed) MemSys string // bytes obtained from system (sum of XxxSys below) Lookups uint64 // number of pointer lookups MemMallocs uint64 // number of mallocs MemFrees uint64 // number of frees // Main allocation heap statistics. HeapAlloc string // bytes allocated and still in use HeapSys string // bytes obtained from system HeapIdle string // bytes in idle spans HeapInuse string // bytes in non-idle span HeapReleased string // bytes released to the OS HeapObjects uint64 // total number of allocated objects // Low-level fixed-size structure allocator statistics. // Inuse is bytes used now. // Sys is bytes obtained from system. StackInuse string // bootstrap stacks StackSys string MSpanInuse string // mspan structures MSpanSys string MCacheInuse string // mcache structures MCacheSys string BuckHashSys string // profiling bucket hash table GCSys string // GC metadata OtherSys string // other system allocations // Garbage collector statistics. NextGC string // next run in HeapAlloc time (bytes) LastGC string // last run in absolute time (ns) PauseTotalNs string PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256] NumGC uint32 } type inspectedCollection struct { CollectionObj Followers int LastPost string } type instanceContent struct { ID string Type string Title sql.NullString Content string Updated time.Time } func (c instanceContent) UpdatedFriendly() string { /* // TODO: accept a locale in this method and use that for the format var loc monday.Locale = monday.LocaleEnUS return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc) */ return c.Updated.Format("January 2, 2006, 3:04 PM") } func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error { updateAppStats() p := struct { *UserPage SysStatus systemStatus Config config.AppCfg Message, ConfigMessage string }{ UserPage: NewUserPage(app, r, u, "Admin", nil), SysStatus: sysStatus, Config: app.cfg.App, Message: r.FormValue("m"), ConfigMessage: r.FormValue("cm"), } showUserPage(w, "admin", p) return nil } func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage Config config.AppCfg Message string Users *[]User CurPage int TotalUsers int64 TotalPages []int }{ UserPage: NewUserPage(app, r, u, "Users", nil), Config: app.cfg.App, Message: r.FormValue("m"), } p.TotalUsers = app.db.GetAllUsersCount() ttlPages := p.TotalUsers / adminUsersPerPage p.TotalPages = []int{} for i := 1; i <= int(ttlPages); i++ { p.TotalPages = append(p.TotalPages, i) } var err error p.CurPage, err = strconv.Atoi(r.FormValue("p")) if err != nil || p.CurPage < 1 { p.CurPage = 1 } else if p.CurPage > int(ttlPages) { p.CurPage = int(ttlPages) } p.Users, err = app.db.GetAllUsers(uint(p.CurPage)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)} } showUserPage(w, "users", p) return nil } func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) username := vars["username"] if username == "" { return impart.HTTPError{http.StatusFound, "/admin/users"} } p := struct { *UserPage Config config.AppCfg Message string User *User Colls []inspectedCollection LastPost string NewPassword string TotalPosts int64 ClearEmail string }{ Config: app.cfg.App, Message: r.FormValue("m"), Colls: []inspectedCollection{}, } var err error p.User, err = app.db.GetUserForAuth(username) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)} } flashes, _ := getSessionFlashes(app, w, r, nil) for _, flash := range flashes { if strings.HasPrefix(flash, "SUCCESS: ") { p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ") p.ClearEmail = p.User.EmailClear(app.keys) } } p.UserPage = NewUserPage(app, r, u, p.User.Username, nil) p.TotalPosts = app.db.GetUserPostsCount(p.User.ID) lp, err := app.db.GetUserLastPostTime(p.User.ID) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)} } if lp != nil { p.LastPost = lp.Format("January 2, 2006, 3:04 PM") } colls, err := app.db.GetCollections(p.User, app.cfg.App.Host) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)} } for _, c := range *colls { ic := inspectedCollection{ CollectionObj: CollectionObj{Collection: c}, } if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(&c) if err == nil { // TODO: handle error here (at least log it) ic.Followers = len(*folls) } } app.db.GetPostsCount(&ic.CollectionObj, true) lp, err := app.db.GetCollectionLastPostTime(c.ID) if err != nil { log.Error("Didn't get last post time for collection %d: %v", c.ID, err) } if lp != nil { ic.LastPost = lp.Format("January 2, 2006, 3:04 PM") } p.Colls = append(p.Colls, ic) } showUserPage(w, "view-user", p) return nil } +func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + username := vars["username"] + if username == "" { + return impart.HTTPError{http.StatusFound, "/admin/users"} + } + + user, err := app.db.GetUserForAuth(username) + if err != nil { + log.Error("failed to get user: %v", err) + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)} + } + if user.IsSilenced() { + err = app.db.SetUserStatus(user.ID, UserActive) + } else { + err = app.db.SetUserStatus(user.ID, UserSilenced) + } + if err != nil { + log.Error("toggle user suspended: %v", err) + return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v")} + } + return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} +} + func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) username := vars["username"] if username == "" { return impart.HTTPError{http.StatusFound, "/admin/users"} } + // Generate new random password since none supplied pass := passgen.NewWordish() hashedPass, err := auth.HashPass([]byte(pass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)} } userIDVal := r.FormValue("user") log.Info("ADMIN: Changing user %s password", userIDVal) id, err := strconv.Atoi(userIDVal) if err != nil { return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)} } err = app.db.ChangePassphrase(int64(id), true, "", hashedPass) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)} } log.Info("ADMIN: Successfully changed.") addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil) return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)} } func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage Config config.AppCfg Message string Pages []*instanceContent }{ UserPage: NewUserPage(app, r, u, "Pages", nil), Config: app.cfg.App, Message: r.FormValue("m"), } var err error p.Pages, err = app.db.GetInstancePages() if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)} } // Add in default pages var hasAbout, hasPrivacy bool for i, c := range p.Pages { if hasAbout && hasPrivacy { break } if c.ID == "about" { hasAbout = true if !c.Title.Valid { p.Pages[i].Title = defaultAboutTitle(app.cfg) } } else if c.ID == "privacy" { hasPrivacy = true if !c.Title.Valid { p.Pages[i].Title = defaultPrivacyTitle() } } } if !hasAbout { p.Pages = append(p.Pages, &instanceContent{ ID: "about", Title: defaultAboutTitle(app.cfg), Content: defaultAboutPage(app.cfg), Updated: defaultPageUpdatedTime, }) } if !hasPrivacy { p.Pages = append(p.Pages, &instanceContent{ ID: "privacy", Title: defaultPrivacyTitle(), Content: defaultPrivacyPolicy(app.cfg), Updated: defaultPageUpdatedTime, }) } showUserPage(w, "pages", p) return nil } func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] if slug == "" { return impart.HTTPError{http.StatusFound, "/admin/pages"} } p := struct { *UserPage Config config.AppCfg Message string Banner *instanceContent Content *instanceContent }{ Config: app.cfg.App, Message: r.FormValue("m"), } var err error // Get pre-defined pages, or select slug if slug == "about" { p.Content, err = getAboutPage(app) } else if slug == "privacy" { p.Content, err = getPrivacyPage(app) } else if slug == "landing" { p.Banner, err = getLandingBanner(app) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)} } p.Content, err = getLandingBody(app) p.Content.ID = "landing" } else if slug == "reader" { p.Content, err = getReaderSection(app) } else { p.Content, err = app.db.GetDynamicContent(slug) } if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)} } title := "New page" if p.Content != nil { title = "Edit " + p.Content.ID } else { p.Content = &instanceContent{} } p.UserPage = NewUserPage(app, r, u, title, nil) showUserPage(w, "view-page", p) return nil } func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) id := vars["page"] // Validate if id != "about" && id != "privacy" && id != "landing" && id != "reader" { return impart.HTTPError{http.StatusNotFound, "No such page."} } var err error m := "" if id == "landing" { // Handle special landing page err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section") if err != nil { m = "?m=" + err.Error() return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m} } err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section") } else if id == "reader" { // Update sections with titles err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section") } else { // Update page err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page") } if err != nil { m = "?m=" + err.Error() } return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m} } func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error { apper.App().cfg.App.SiteName = r.FormValue("site_name") apper.App().cfg.App.SiteDesc = r.FormValue("site_desc") apper.App().cfg.App.Landing = r.FormValue("landing") apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on" mul, err := strconv.Atoi(r.FormValue("min_username_len")) if err == nil { apper.App().cfg.App.MinUsernameLen = mul } mb, err := strconv.Atoi(r.FormValue("max_blogs")) if err == nil { apper.App().cfg.App.MaxBlogs = mb } apper.App().cfg.App.Federation = r.FormValue("federation") == "on" apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on" apper.App().cfg.App.Private = r.FormValue("private") == "on" apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil { log.Info("Initializing local timeline...") initLocalTimeline(apper.App()) } apper.App().cfg.App.UserInvites = r.FormValue("user_invites") if apper.App().cfg.App.UserInvites == "none" { apper.App().cfg.App.UserInvites = "" } apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility") m := "?cm=Configuration+saved." err = apper.SaveConfig(apper.App().cfg) if err != nil { m = "?cm=" + err.Error() } return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"} } func updateAppStats() { sysStatus.Uptime = appstats.TimeSincePro(appStartTime) m := new(runtime.MemStats) runtime.ReadMemStats(m) sysStatus.NumGoroutine = runtime.NumGoroutine() sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc)) sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc)) sysStatus.MemSys = appstats.FileSize(int64(m.Sys)) sysStatus.Lookups = m.Lookups sysStatus.MemMallocs = m.Mallocs sysStatus.MemFrees = m.Frees sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc)) sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys)) sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle)) sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse)) sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased)) sysStatus.HeapObjects = m.HeapObjects sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse)) sysStatus.StackSys = appstats.FileSize(int64(m.StackSys)) sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse)) sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys)) sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse)) sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys)) sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys)) sysStatus.GCSys = appstats.FileSize(int64(m.GCSys)) sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys)) sysStatus.NextGC = appstats.FileSize(int64(m.NextGC)) sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000) sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000) sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000) sysStatus.NumGC = m.NumGC } func adminResetPassword(app *App, u *User, newPass string) error { hashedPass, err := auth.HashPass([]byte(newPass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)} } err = app.db.ChangePassphrase(u.ID, true, "", hashedPass) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)} } return nil } diff --git a/collections.go b/collections.go index cdf3d5c..54ddc8a 100644 --- a/collections.go +++ b/collections.go @@ -1,1090 +1,1122 @@ /* * Copyright © 2018 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "encoding/json" "fmt" "html/template" "math" "net/http" "net/url" "regexp" "strconv" "strings" "unicode" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/bots" "github.com/writeas/web-core/log" waposts "github.com/writeas/web-core/posts" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) type ( // TODO: add Direction to db // TODO: add Language to db Collection struct { ID int64 `datastore:"id" json:"-"` Alias string `datastore:"alias" schema:"alias" json:"alias"` Title string `datastore:"title" schema:"title" json:"title"` Description string `datastore:"description" schema:"description" json:"description"` Direction string `schema:"dir" json:"dir,omitempty"` Language string `schema:"lang" json:"lang,omitempty"` StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"` Script string `datastore:"script" schema:"script" json:"script,omitempty"` Public bool `datastore:"public" json:"public"` Visibility collVisibility `datastore:"private" json:"-"` Format string `datastore:"format" json:"format,omitempty"` Views int64 `json:"views"` OwnerID int64 `datastore:"owner_id" json:"-"` PublicOwner bool `datastore:"public_owner" json:"-"` URL string `json:"url,omitempty"` db *datastore hostName string } CollectionObj struct { Collection TotalPosts int `json:"total_posts"` Owner *User `json:"owner,omitempty"` Posts *[]PublicPost `json:"posts,omitempty"` } DisplayCollection struct { *CollectionObj Prefix string IsTopLevel bool CurrentPage int TotalPages int Format *CollectionFormat + Suspended bool } SubmittedCollection struct { // Data used for updating a given collection ID int64 OwnerID uint64 // Form helpers PreferURL string `schema:"prefer_url" json:"prefer_url"` Privacy int `schema:"privacy" json:"privacy"` Pass string `schema:"password" json:"password"` MathJax bool `schema:"mathjax" json:"mathjax"` Handle string `schema:"handle" json:"handle"` // Actual collection values updated in the DB Alias *string `schema:"alias" json:"alias"` Title *string `schema:"title" json:"title"` Description *string `schema:"description" json:"description"` StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` Script *sql.NullString `schema:"script" json:"script"` Visibility *int `schema:"visibility" json:"public"` Format *sql.NullString `schema:"format" json:"format"` } CollectionFormat struct { Format string } collectionReq struct { // Information about the collection request itself prefix, alias, domain string isCustomDomain bool // User-related fields isCollOwner bool } ) func (sc *SubmittedCollection) FediverseHandle() string { if sc.Handle == "" { return apCustomHandleDefault } return getSlug(sc.Handle, "") } // collVisibility represents the visibility level for the collection. type collVisibility int // Visibility levels. Values are bitmasks, stored in the database as // decimal numbers. If adding types, append them to this list. If removing, // replace the desired visibility with a new value. const CollUnlisted collVisibility = 0 const ( CollPublic collVisibility = 1 << iota CollPrivate CollProtected ) var collVisibilityStrings = map[string]collVisibility{ "unlisted": CollUnlisted, "public": CollPublic, "private": CollPrivate, "protected": CollProtected, } func defaultVisibility(cfg *config.Config) collVisibility { vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility] if !ok { vis = CollUnlisted } return vis } func (cf *CollectionFormat) Ascending() bool { return cf.Format == "novel" } func (cf *CollectionFormat) ShowDates() bool { return cf.Format == "blog" } func (cf *CollectionFormat) PostsPerPage() int { if cf.Format == "novel" { return postsPerPage } return postsPerPage } // Valid returns whether or not a format value is valid. func (cf *CollectionFormat) Valid() bool { return cf.Format == "blog" || cf.Format == "novel" || cf.Format == "notebook" } // NewFormat creates a new CollectionFormat object from the Collection. func (c *Collection) NewFormat() *CollectionFormat { cf := &CollectionFormat{Format: c.Format} // Fill in default format if cf.Format == "" { cf.Format = "blog" } return cf } func (c *Collection) IsUnlisted() bool { return c.Visibility == 0 } func (c *Collection) IsPrivate() bool { return c.Visibility&CollPrivate != 0 } func (c *Collection) IsProtected() bool { return c.Visibility&CollProtected != 0 } func (c *Collection) IsPublic() bool { return c.Visibility&CollPublic != 0 } func (c *Collection) FriendlyVisibility() string { if c.IsPrivate() { return "Private" } if c.IsPublic() { return "Public" } if c.IsProtected() { return "Password-protected" } return "Unlisted" } func (c *Collection) ShowFooterBranding() bool { // TODO: implement this setting return true } // CanonicalURL returns a fully-qualified URL to the collection. func (c *Collection) CanonicalURL() string { return c.RedirectingCanonicalURL(false) } func (c *Collection) DisplayCanonicalURL() string { us := c.CanonicalURL() u, err := url.Parse(us) if err != nil { return us } p := u.Path if p == "/" { p = "" } return u.Hostname() + p } func (c *Collection) RedirectingCanonicalURL(isRedir bool) string { if c.hostName == "" { // If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writeas/writefreely/issues/new?template=bug_report.md") } if isSingleUser { return c.hostName + "/" } return fmt.Sprintf("%s/%s/", c.hostName, c.Alias) } // PrevPageURL provides a full URL for the previous page of collection posts, // returning a /page/N result for pages >1 func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string { u := "" if n == 2 { // Previous page is 1; no need for /page/ prefix if prefix == "" { u = "/" } // Else leave off trailing slash } else { u = fmt.Sprintf("/page/%d", n-1) } if tl { return u } return "/" + prefix + c.Alias + u } // NextPageURL provides a full URL for the next page of collection posts func (c *Collection) NextPageURL(prefix string, n int, tl bool) string { if tl { return fmt.Sprintf("/page/%d", n+1) } return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1) } func (c *Collection) DisplayTitle() string { if c.Title != "" { return c.Title } return c.Alias } func (c *Collection) StyleSheetDisplay() template.CSS { return template.CSS(c.StyleSheet) } // ForPublic modifies the Collection for public consumption, such as via // the API. func (c *Collection) ForPublic() { c.URL = c.CanonicalURL() } var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person { accountRoot := c.FederatedAccount() p := activitystreams.NewPerson(accountRoot) p.URL = c.CanonicalURL() uname := c.Alias p.PreferredUsername = uname p.Name = c.DisplayTitle() p.Summary = c.Description if p.Name != "" { if av := c.AvatarURL(); av != "" { p.Icon = activitystreams.Image{ Type: "Image", MediaType: "image/png", URL: av, } } } collID := c.ID if len(ids) > 0 { collID = ids[0] } pub, priv := c.db.GetAPActorKeys(collID) if pub != nil { p.AddPubKey(pub) p.SetPrivKey(priv) } return p } func (c *Collection) AvatarURL() string { fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0])) if !isAvatarChar(fl) { return "" } return c.hostName + "/img/avatars/" + fl + ".png" } func (c *Collection) FederatedAPIBase() string { return c.hostName + "/" } func (c *Collection) FederatedAccount() string { accountUser := c.Alias return c.FederatedAPIBase() + "api/collections/" + accountUser } func (c *Collection) RenderMathJax() bool { return c.db.CollectionHasAttribute(c.ID, "render_mathjax") } func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) alias := r.FormValue("alias") title := r.FormValue("title") var missingParams, accessToken string var u *User c := struct { Alias string `json:"alias" schema:"alias"` Title string `json:"title" schema:"title"` Web bool `json:"web" schema:"web"` }{} if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err := decoder.Decode(&c) if err != nil { log.Error("Couldn't parse post update JSON request: %v\n", err) return ErrBadJSON } } else { // TODO: move form parsing to formDecoder c.Alias = alias c.Title = title } if c.Alias == "" { if c.Title != "" { // If only a title was given, just use it to generate the alias. c.Alias = getSlug(c.Title, "") } else { missingParams += "`alias` " } } if c.Title == "" { missingParams += "`title` " } if missingParams != "" { return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)} } var userID int64 + var err error if reqJSON && !c.Web { accessToken = r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } userID = app.db.GetUserID(accessToken) if userID == -1 { return ErrBadAccessToken } } else { u = getUserSession(app, r) if u == nil { return ErrNotLoggedIn } userID = u.ID } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("new collection: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } if !author.IsValidUsername(app.cfg, c.Alias) { return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."} } coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID) if err != nil { // TODO: handle this return err } res := &CollectionObj{Collection: *coll} if reqJSON { return impart.WriteSuccess(w, res, http.StatusCreated) } redirectTo := "/me/c/" // TODO: redirect to pad when necessary return impart.HTTPError{http.StatusFound, redirectTo} } func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) { accessToken := r.Header.Get("Authorization") var userID int64 = -1 if accessToken != "" { userID = app.db.GetUserID(accessToken) } isCollOwner := userID == c.OwnerID if c.IsPrivate() && !isCollOwner { // Collection is private, but user isn't authenticated return -1, ErrCollectionNotFound } if c.IsProtected() { // TODO: check access token return -1, ErrCollectionUnauthorizedRead } return userID, nil } // fetchCollection handles the API endpoint for retrieving collection data. func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { accept := r.Header.Get("Accept") if strings.Contains(accept, "application/activity+json") { return handleFetchCollectionActivities(app, w, r) } vars := mux.Vars(r) alias := vars["alias"] // TODO: move this logic into a common getCollection function // Get base Collection data c, err := app.db.GetCollection(alias) if err != nil { return err } c.hostName = app.cfg.App.Host // Redirect users who aren't requesting JSON reqJSON := IsJSON(r) if !reqJSON { return impart.HTTPError{http.StatusFound, c.CanonicalURL()} } // Check permissions userID, err := apiCheckCollectionPermissions(app, r, c) if err != nil { return err } isCollOwner := userID == c.OwnerID // Fetch extra data about the Collection res := &CollectionObj{Collection: *c} if c.PublicOwner { u, err := app.db.GetUserByID(res.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } else { res.Owner = u } } + // TODO: check suspended app.db.GetPostsCount(res, isCollOwner) // Strip non-public information res.Collection.ForPublic() return impart.WriteSuccess(w, res, http.StatusOK) } // fetchCollectionPosts handles an API endpoint for retrieving a collection's // posts. func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) alias := vars["alias"] c, err := app.db.GetCollection(alias) if err != nil { return err } c.hostName = app.cfg.App.Host // Check permissions userID, err := apiCheckCollectionPermissions(app, r, c) if err != nil { return err } isCollOwner := userID == c.OwnerID // Get page page := 1 if p := r.FormValue("page"); p != "" { pInt, _ := strconv.Atoi(p) if pInt > 0 { page = pInt } } posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false) if err != nil { return err } coll := &CollectionObj{Collection: *c, Posts: posts} app.db.GetPostsCount(coll, isCollOwner) // Strip non-public information coll.Collection.ForPublic() // Transform post bodies if needed if r.FormValue("body") == "html" { for _, p := range *coll.Posts { p.Content = waposts.ApplyMarkdown([]byte(p.Content)) } } return impart.WriteSuccess(w, coll, http.StatusOK) } type CollectionPage struct { page.StaticPage *DisplayCollection IsCustomDomain bool IsWelcome bool IsOwner bool CanPin bool Username string Collections *[]Collection PinnedPosts *[]PublicPost IsAdmin bool CanInvite bool } func (c *CollectionObj) ScriptDisplay() template.JS { return template.JS(c.Script) } var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$") func (c *CollectionObj) ExternalScripts() []template.URL { scripts := []template.URL{} if c.Script == "" { return scripts } matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1) for _, m := range matches { scripts = append(scripts, template.URL(strings.TrimSpace(m[1]))) } return scripts } func (c *CollectionObj) CanShowScript() bool { return false } func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error { cr.prefix = vars["prefix"] cr.alias = vars["collection"] // Normalize the URL, redirecting user to consistent post URL if cr.alias != strings.ToLower(cr.alias) { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))} } return nil } // processCollectionPermissions checks the permissions for the given // collectionReq, returning a Collection if access is granted; otherwise this // renders any necessary collection pages, for example, if requesting a custom // domain that doesn't yet have a collection associated, or if a collection // requires a password. In either case, this will return nil, nil -- thus both // values should ALWAYS be checked to determine whether or not to continue. func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) { // Display collection if this is a collection var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(cr.alias) } // TODO: verify we don't reveal the existence of a private collection with redirection if err != nil { if err, ok := err.(impart.HTTPError); ok { if err.Status == http.StatusNotFound { if cr.isCustomDomain { // User is on the site from a custom domain //tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r)) //if tErr != nil { //log.Error("Unable to render 404-domain page: %v", err) //} return nil, nil } if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen { // Alias is within post ID range, so just be sure this isn't a post if app.db.PostIDExists(cr.alias) { // TODO: use StatusFound for vanity post URLs when we implement them return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias} } } // Redirect if necessary newAlias := app.db.GetCollectionRedirect(cr.alias) if newAlias != "" { return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"} } } } return nil, err } c.hostName = app.cfg.App.Host // Update CollectionRequest to reflect owner status cr.isCollOwner = u != nil && u.ID == c.OwnerID // Check permissions if !cr.isCollOwner { if c.IsPrivate() { return nil, ErrCollectionNotFound } else if c.IsProtected() { uname := "" if u != nil { uname = u.Username } // See if we've authorized this collection authd := isAuthorizedForCollection(app, c.Alias, r) if !authd { p := struct { page.StaticPage *CollectionObj Username string Next string Flashes []template.HTML }{ StaticPage: pageForReq(app, r), CollectionObj: &CollectionObj{Collection: *c}, Username: uname, Next: r.FormValue("g"), Flashes: []template.HTML{}, } // Get owner information p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } flashes, _ := getSessionFlashes(app, w, r, nil) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p) if err != nil { log.Error("Unable to render password-collection: %v", err) return nil, err } return nil, nil } } } return c, nil } func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) { u := getUserSession(app, r) return u, nil } func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection { coll := &DisplayCollection{ CollectionObj: &CollectionObj{Collection: *c}, CurrentPage: page, Prefix: cr.prefix, IsTopLevel: isSingleUser, Format: c.NewFormat(), } c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner) return coll } func getCollectionPage(vars map[string]string) int { page := 1 var p int p, _ = strconv.Atoi(vars["page"]) if p > 0 { page = p } return page } // handleViewCollection displays the requested Collection func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } u, err := checkUserForCollection(app, cr, r, false) if err != nil { return err } page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) if c == nil || err != nil { return err } - c.hostName = app.cfg.App.Host + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("view collection: %v", err) + return ErrInternalGeneral + } + // Serve ActivityStreams data now, if requested if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { ac := c.PersonObject() ac.Context = []interface{}{activitystreams.Namespace} return impart.RenderActivityJSON(w, ac, http.StatusOK) } // Fetch extra data about the Collection // TODO: refactor out this logic, shared in collection.go:fetchCollection() coll := newDisplayCollection(c, cr, page) coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage()))) if coll.TotalPages > 0 && page > coll.TotalPages { redirURL := fmt.Sprintf("/page/%d", coll.TotalPages) if !app.cfg.App.SingleUser { redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL) } return impart.HTTPError{http.StatusFound, redirURL} } coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false) // Serve collection displayPage := CollectionPage{ DisplayCollection: coll, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, IsWelcome: r.FormValue("greeting") != "", } displayPage.IsAdmin = u != nil && u.IsAdmin() displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin) var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u displayPage.CanPin = true pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } displayPage.Collections = pubColls } } isOwner := owner != nil if !isOwner { // Current user doesn't own collection; retrieve owner information owner, err = app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } } + if !isOwner && suspended { + return ErrCollectionNotFound + } + displayPage.Suspended = isOwner && suspended displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) collTmpl := "collection" if app.cfg.App.Chorus { collTmpl = "chorus-collection" } err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage) if err != nil { log.Error("Unable to render collection index: %v", err) } // Update collection view count go func() { // Don't update if owner is viewing the collection. if u != nil && u.ID == coll.OwnerID { return } // Only update for human views if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) { return } _, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID) if err != nil { log.Error("Unable to update collections count: %v", err) } }() return err } func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) tag := vars["tag"] cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } u, err := checkUserForCollection(app, cr, r, false) if err != nil { return err } page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) if c == nil || err != nil { return err } coll := newDisplayCollection(c, cr, page) coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner) if coll.Posts != nil && len(*coll.Posts) == 0 { return ErrCollectionPageNotFound } // Serve collection displayPage := struct { CollectionPage Tag string }{ CollectionPage: CollectionPage{ DisplayCollection: coll, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, }, Tag: tag, } var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u displayPage.CanPin = true pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } displayPage.Collections = pubColls } } isOwner := owner != nil if !isOwner { // Current user doesn't own collection; retrieve owner information owner, err = app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } } + if !isOwner && u.IsSilenced() { + return ErrCollectionNotFound + } + displayPage.Suspended = u.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) if err != nil { log.Error("Unable to render collection tag page: %v", err) } return nil } func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } // Normalize the URL, redirecting user to consistent post URL loc := fmt.Sprintf("/%s", slug) if !app.cfg.App.SingleUser { loc = fmt.Sprintf("/%s/%s", cr.alias, slug) } return impart.HTTPError{http.StatusFound, loc} } func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) collAlias := vars["alias"] isWeb := r.FormValue("web") == "1" - var u *User + u := &User{} if reqJSON && !isWeb { // Ensure an access token was given accessToken := r.Header.Get("Authorization") - u = &User{} u.ID = app.db.GetUserID(accessToken) if u.ID == -1 { return ErrBadAccessToken } } else { u = getUserSession(app, r) if u == nil { return ErrNotLoggedIn } } + suspended, err := app.db.IsUserSuspended(u.ID) + if err != nil { + log.Error("existing collection: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrUserSuspended + } + if r.Method == "DELETE" { err := app.db.DeleteCollection(collAlias, u.ID) if err != nil { // TODO: if not HTTPError, report error to admin log.Error("Unable to delete collection: %s", err) return err } addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil) return impart.HTTPError{Status: http.StatusNoContent} } c := SubmittedCollection{OwnerID: uint64(u.ID)} - var err error if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&c) if err != nil { log.Error("Couldn't parse collection update JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse collection update form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&c, r.PostForm) if err != nil { log.Error("Couldn't decode collection update form request: %v\n", err) return ErrBadFormData } } err = app.db.UpdateCollection(&c, collAlias) if err != nil { if err, ok := err.(impart.HTTPError); ok { if reqJSON { return err } addSessionFlash(app, w, r, err.Message, nil) return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias} } else { log.Error("Couldn't update collection: %v\n", err) return err } } if reqJSON { return impart.WriteSuccess(w, struct { }{}, http.StatusOK) } addSessionFlash(app, w, r, "Blog updated!", nil) return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias} } // collectionAliasFromReq takes a request and returns the collection alias // if it can be ascertained, as well as whether or not the collection uses a // custom domain. func collectionAliasFromReq(r *http.Request) string { vars := mux.Vars(r) alias := vars["subdomain"] isSubdomain := alias != "" if !isSubdomain { // Fall back to write.as/{collection} since this isn't a custom domain alias = vars["collection"] } return alias } func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error { var readReq struct { Alias string `schema:"alias" json:"alias"` Pass string `schema:"password" json:"password"` Next string `schema:"to" json:"to"` } // Get params if impart.ReqJSON(r) { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&readReq) if err != nil { log.Error("Couldn't parse readReq JSON request: %v\n", err) return ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse readReq form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&readReq, r.PostForm) if err != nil { log.Error("Couldn't decode readReq form request: %v\n", err) return ErrBadFormData } } if readReq.Alias == "" { return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."} } if readReq.Pass == "" { return impart.HTTPError{http.StatusBadRequest, "Please supply a password."} } var collHashedPass []byte err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass) if err != nil { if err == sql.ErrNoRows { log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias) return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."} } return err } if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } // Success; set cookie session, err := app.sessionStore.Get(r, blogPassCookieName) if err == nil { session.Values[readReq.Alias] = true err = session.Save(r, w) if err != nil { log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err) } } next := "/" + readReq.Next if !app.cfg.App.SingleUser { next = "/" + readReq.Alias + next } return impart.HTTPError{http.StatusFound, next} } func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool { authd := false session, err := app.sessionStore.Get(r, blogPassCookieName) if err == nil { _, authd = session.Values[alias] } return authd } diff --git a/database.go b/database.go index a3235b6..d78d888 100644 --- a/database.go +++ b/database.go @@ -1,2451 +1,2485 @@ /* * Copyright © 2018 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "fmt" "net/http" "strings" "time" "github.com/guregu/null" "github.com/guregu/null/zero" uuid "github.com/nu7hatch/gouuid" "github.com/writeas/impart" "github.com/writeas/nerds/store" "github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/id" "github.com/writeas/web-core/log" "github.com/writeas/web-core/query" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/key" ) const ( mySQLErrDuplicateKey = 1062 driverMySQL = "mysql" driverSQLite = "sqlite3" ) var ( SQLiteEnabled bool ) type writestore interface { CreateUser(*config.Config, *User, string) error UpdateUserEmail(keys *key.Keychain, userID int64, email string) error UpdateEncryptedUserEmail(int64, []byte) error GetUserByID(int64) (*User, error) GetUserForAuth(string) (*User, error) GetUserForAuthByID(int64) (*User, error) GetUserNameFromToken(string) (string, error) GetUserDataFromToken(string) (int64, string, error) GetAPIUser(header string) (*User, error) GetUserID(accessToken string) int64 GetUserIDPrivilege(accessToken string) (userID int64, sudo bool) DeleteToken(accessToken []byte) error FetchLastAccessToken(userID int64) string GetAccessToken(userID int64) (string, error) GetTemporaryAccessToken(userID int64, validSecs int) (string, error) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error) DeleteAccount(userID int64) (l *string, err error) ChangeSettings(app *App, u *User, s *userSettings) error ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error GetCollections(u *User, hostName string) (*[]Collection, error) GetPublishableCollections(u *User, hostName string) (*[]Collection, error) GetMeStats(u *User) userMeStats GetTotalCollections() (int64, error) GetTotalPosts() (int64, error) GetTopPosts(u *User, alias string) (*[]PublicPost, error) GetAnonymousPosts(u *User) (*[]PublicPost, error) GetUserPosts(u *User) (*[]PublicPost, error) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) UpdateOwnedPost(post *AuthenticatedPost, userID int64) error GetEditablePost(id, editToken string) (*PublicPost, error) PostIDExists(id string) bool GetPost(id string, collectionID int64) (*PublicPost, error) GetOwnedPost(id string, ownerID int64) (*PublicPost, error) GetPostProperty(id string, collectionID int64, property string) (interface{}, error) CreateCollectionFromToken(*config.Config, string, string, string) (*Collection, error) CreateCollection(*config.Config, string, string, int64) (*Collection, error) GetCollectionBy(condition string, value interface{}) (*Collection, error) GetCollection(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error) GetCollectionByID(id int64) (*Collection, error) UpdateCollection(c *SubmittedCollection, alias string) error DeleteCollection(alias string, userID int64) error UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error GetLastPinnedPostPos(collID int64) int64 GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[]PublicPost, error) RemoveCollectionRedirect(t *sql.Tx, alias string) error GetCollectionRedirect(alias string) (new string) IsCollectionAttributeOn(id int64, attr string) bool CollectionHasAttribute(id int64, attr string) bool CanCollect(cpr *ClaimPostRequest, userID int64) bool AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error) ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) GetPostsCount(c *CollectionObj, includeFuture bool) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) GetAPFollowers(c *Collection) (*[]RemoteUser, error) GetAPActorKeys(collectionID int64) ([]byte, []byte) CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error GetUserInvites(userID int64) (*[]Invite, error) GetUserInvite(id string) (*Invite, error) GetUsersInvitedCount(id string) int64 CreateInvitedUser(inviteID string, userID int64) error GetDynamicContent(id string) (*instanceContent, error) UpdateDynamicContent(id, title, content, contentType string) error GetAllUsers(page uint) (*[]User, error) GetAllUsersCount() int64 GetUserLastPostTime(id int64) (*time.Time, error) GetCollectionLastPostTime(id int64) (*time.Time, error) DatabaseInitialized() bool } type datastore struct { *sql.DB driverName string } func (db *datastore) now() string { if db.driverName == driverSQLite { return "strftime('%Y-%m-%d %H:%M:%S','now')" } return "NOW()" } func (db *datastore) clip(field string, l int) string { if db.driverName == driverSQLite { return fmt.Sprintf("SUBSTR(%s, 0, %d)", field, l) } return fmt.Sprintf("LEFT(%s, %d)", field, l) } func (db *datastore) upsert(indexedCols ...string) string { if db.driverName == driverSQLite { // NOTE: SQLite UPSERT syntax only works in v3.24.0 (2018-06-04) or later // Leaving this for whenever we can upgrade and include it in our binary cc := strings.Join(indexedCols, ", ") return "ON CONFLICT(" + cc + ") DO UPDATE SET" } return "ON DUPLICATE KEY UPDATE" } func (db *datastore) dateSub(l int, unit string) string { if db.driverName == driverSQLite { return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit) } return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit) } func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error { if db.PostIDExists(u.Username) { return impart.HTTPError{http.StatusConflict, "Invalid collection name."} } // New users get a `users` and `collections` row. t, err := db.Begin() if err != nil { return err } // 1. Add to `users` table // NOTE: Assumes User's Password is already hashed! res, err := t.Exec("INSERT INTO users (username, password, email) VALUES (?, ?, ?)", u.Username, u.HashedPass, u.Email) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { return impart.HTTPError{http.StatusConflict, "Username is already taken."} } log.Error("Rolling back users INSERT: %v\n", err) return err } u.ID, err = res.LastInsertId() if err != nil { t.Rollback() log.Error("Rolling back after LastInsertId: %v\n", err) return err } // 2. Create user's Collection if collectionTitle == "" { collectionTitle = u.Username } res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", defaultVisibility(cfg), u.ID, 0) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { return impart.HTTPError{http.StatusConflict, "Username is already taken."} } log.Error("Rolling back collections INSERT: %v\n", err) return err } db.RemoveCollectionRedirect(t, u.Username) err = t.Commit() if err != nil { t.Rollback() log.Error("Rolling back after Commit(): %v\n", err) return err } return nil } // FIXME: We're returning errors inconsistently in this file. Do we use Errorf // for returned value, or impart? func (db *datastore) UpdateUserEmail(keys *key.Keychain, userID int64, email string) error { encEmail, err := data.Encrypt(keys.EmailKey, email) if err != nil { return fmt.Errorf("Couldn't encrypt email %s: %s\n", email, err) } return db.UpdateEncryptedUserEmail(userID, encEmail) } func (db *datastore) UpdateEncryptedUserEmail(userID int64, encEmail []byte) error { _, err := db.Exec("UPDATE users SET email = ? WHERE id = ?", encEmail, userID) if err != nil { return fmt.Errorf("Unable to update user email: %s", err) } return nil } func (db *datastore) CreateCollectionFromToken(cfg *config.Config, alias, title, accessToken string) (*Collection, error) { userID := db.GetUserID(accessToken) if userID == -1 { return nil, ErrBadAccessToken } return db.CreateCollection(cfg, alias, title, userID) } func (db *datastore) GetUserCollectionCount(userID int64) (uint64, error) { var collCount uint64 err := db.QueryRow("SELECT COUNT(*) FROM collections WHERE owner_id = ?", userID).Scan(&collCount) switch { case err == sql.ErrNoRows: return 0, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user from database."} case err != nil: log.Error("Couldn't get collections count for user %d: %v", userID, err) return 0, err } return collCount, nil } func (db *datastore) CreateCollection(cfg *config.Config, alias, title string, userID int64) (*Collection, error) { if db.PostIDExists(alias) { return nil, impart.HTTPError{http.StatusConflict, "Invalid collection name."} } // All good, so create new collection res, err := db.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", defaultVisibility(cfg), userID, 0) if err != nil { if db.isDuplicateKeyErr(err) { return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."} } log.Error("Couldn't add to collections: %v\n", err) return nil, err } c := &Collection{ Alias: alias, Title: title, OwnerID: userID, PublicOwner: false, Public: defaultVisibility(cfg) == CollPublic, } c.ID, err = res.LastInsertId() if err != nil { log.Error("Couldn't get collection LastInsertId: %v\n", err) } return c, nil } func (db *datastore) GetUserByID(id int64) (*User, error) { u := &User{ID: id} - err := db.QueryRow("SELECT username, password, email, created FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created) + err := db.QueryRow("SELECT username, password, email, created, status FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound case err != nil: log.Error("Couldn't SELECT user password: %v", err) return nil, err } return u, nil } +// IsUserSuspended returns true if the user account associated with id is +// currently suspended. +func (db *datastore) IsUserSuspended(id int64) (bool, error) { + u := &User{ID: id} + + err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status) + switch { + case err == sql.ErrNoRows: + return false, fmt.Errorf("is user suspended: %v", ErrUserNotFound) + case err != nil: + log.Error("Couldn't SELECT user password: %v", err) + return false, fmt.Errorf("is user suspended: %v", err) + } + + return u.IsSilenced(), nil +} + // DoesUserNeedAuth returns true if the user hasn't provided any methods for // authenticating with the account, such a passphrase or email address. // Any errors are reported to admin and silently quashed, returning false as the // result. func (db *datastore) DoesUserNeedAuth(id int64) bool { var pass, email []byte // Find out if user has an email set first err := db.QueryRow("SELECT password, email FROM users WHERE id = ?", id).Scan(&pass, &email) switch { case err == sql.ErrNoRows: // ERROR. Don't give false positives on needing auth methods return false case err != nil: // ERROR. Don't give false positives on needing auth methods log.Error("Couldn't SELECT user %d from users: %v", id, err) return false } // User doesn't need auth if there's an email return len(email) == 0 && len(pass) == 0 } func (db *datastore) IsUserPassSet(id int64) (bool, error) { var pass []byte err := db.QueryRow("SELECT password FROM users WHERE id = ?", id).Scan(&pass) switch { case err == sql.ErrNoRows: return false, nil case err != nil: log.Error("Couldn't SELECT user %d from users: %v", id, err) return false, err } return len(pass) > 0, nil } func (db *datastore) GetUserForAuth(username string) (*User, error) { u := &User{Username: username} - err := db.QueryRow("SELECT id, password, email, created FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) + err := db.QueryRow("SELECT id, password, email, created, status FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: // Check if they've entered the wrong, unnormalized username username = getSlug(username, "") if username != u.Username { err = db.QueryRow("SELECT id FROM users WHERE username = ? LIMIT 1", username).Scan(&u.ID) if err == nil { return db.GetUserForAuth(username) } } return nil, ErrUserNotFound case err != nil: log.Error("Couldn't SELECT user password: %v", err) return nil, err } return u, nil } func (db *datastore) GetUserForAuthByID(userID int64) (*User, error) { u := &User{ID: userID} - err := db.QueryRow("SELECT id, password, email, created FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created) + err := db.QueryRow("SELECT id, password, email, created, status FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status) switch { case err == sql.ErrNoRows: return nil, ErrUserNotFound case err != nil: log.Error("Couldn't SELECT userForAuthByID: %v", err) return nil, err } return u, nil } func (db *datastore) GetUserNameFromToken(accessToken string) (string, error) { t := auth.GetToken(accessToken) if len(t) == 0 { return "", ErrNoAccessToken } var oneTime bool var username string err := db.QueryRow("SELECT username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&username, &oneTime) switch { case err == sql.ErrNoRows: return "", ErrBadAccessToken case err != nil: return "", ErrInternalGeneral } // Delete token if it was one-time if oneTime { db.DeleteToken(t[:]) } return username, nil } func (db *datastore) GetUserDataFromToken(accessToken string) (int64, string, error) { t := auth.GetToken(accessToken) if len(t) == 0 { return 0, "", ErrNoAccessToken } var userID int64 var oneTime bool var username string err := db.QueryRow("SELECT user_id, username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&userID, &username, &oneTime) switch { case err == sql.ErrNoRows: return 0, "", ErrBadAccessToken case err != nil: return 0, "", ErrInternalGeneral } // Delete token if it was one-time if oneTime { db.DeleteToken(t[:]) } return userID, username, nil } func (db *datastore) GetAPIUser(header string) (*User, error) { uID := db.GetUserID(header) if uID == -1 { return nil, fmt.Errorf(ErrUserNotFound.Error()) } return db.GetUserByID(uID) } // GetUserID takes a hexadecimal accessToken, parses it into its binary // representation, and gets any user ID associated with the token. If no user // is associated, -1 is returned. func (db *datastore) GetUserID(accessToken string) int64 { i, _ := db.GetUserIDPrivilege(accessToken) return i } func (db *datastore) GetUserIDPrivilege(accessToken string) (userID int64, sudo bool) { t := auth.GetToken(accessToken) if len(t) == 0 { return -1, false } var oneTime bool err := db.QueryRow("SELECT user_id, sudo, one_time FROM accesstokens WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&userID, &sudo, &oneTime) switch { case err == sql.ErrNoRows: return -1, false case err != nil: return -1, false } // Delete token if it was one-time if oneTime { db.DeleteToken(t[:]) } return } func (db *datastore) DeleteToken(accessToken []byte) error { res, err := db.Exec("DELETE FROM accesstokens WHERE token LIKE ?", accessToken) if err != nil { return err } rowsAffected, _ := res.RowsAffected() if rowsAffected == 0 { return impart.HTTPError{http.StatusNotFound, "Token is invalid or doesn't exist"} } return nil } // FetchLastAccessToken creates a new non-expiring, valid access token for the given // userID. func (db *datastore) FetchLastAccessToken(userID int64) string { var t []byte err := db.QueryRow("SELECT token FROM accesstokens WHERE user_id = ? AND (expires IS NULL OR expires > "+db.now()+") ORDER BY created DESC LIMIT 1", userID).Scan(&t) switch { case err == sql.ErrNoRows: return "" case err != nil: log.Error("Failed selecting from accesstoken: %v", err) return "" } u, err := uuid.Parse(t) if err != nil { return "" } return u.String() } // GetAccessToken creates a new non-expiring, valid access token for the given // userID. func (db *datastore) GetAccessToken(userID int64) (string, error) { return db.GetTemporaryOneTimeAccessToken(userID, 0, false) } // GetTemporaryAccessToken creates a new valid access token for the given // userID that remains valid for the given time in seconds. If validSecs is 0, // the access token doesn't automatically expire. func (db *datastore) GetTemporaryAccessToken(userID int64, validSecs int) (string, error) { return db.GetTemporaryOneTimeAccessToken(userID, validSecs, false) } // GetTemporaryOneTimeAccessToken creates a new valid access token for the given // userID that remains valid for the given time in seconds and can only be used // once if oneTime is true. If validSecs is 0, the access token doesn't // automatically expire. func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error) { u, err := uuid.NewV4() if err != nil { log.Error("Unable to generate token: %v", err) return "", err } // Insert UUID to `accesstokens` binTok := u[:] expirationVal := "NULL" if validSecs > 0 { expirationVal = fmt.Sprintf("DATE_ADD("+db.now()+", INTERVAL %d SECOND)", validSecs) } _, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+expirationVal+")", string(binTok), userID, oneTime) if err != nil { log.Error("Couldn't INSERT accesstoken: %v", err) return "", err } return u.String(), nil } func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) { var userID, collID int64 = -1, -1 var coll *Collection var err error if accessToken != "" { userID = db.GetUserID(accessToken) if userID == -1 { return nil, ErrBadAccessToken } if collAlias != "" { coll, err = db.GetCollection(collAlias) if err != nil { return nil, err } coll.hostName = hostName if coll.OwnerID != userID { return nil, ErrForbiddenCollection } collID = coll.ID } } rp := &PublicPost{} rp.Post, err = db.CreatePost(userID, collID, post) if err != nil { return rp, err } if coll != nil { coll.ForPublic() rp.Collection = &CollectionObj{Collection: *coll} } return rp, nil } func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) { idLen := postIDLen friendlyID := store.GenerateFriendlyRandomString(idLen) // Handle appearance / font face appearance := post.Font if !post.isFontValid() { appearance = "norm" } var err error ownerID := sql.NullInt64{ Valid: false, } ownerCollID := sql.NullInt64{ Valid: false, } slug := sql.NullString{"", false} // If an alias was supplied, we'll add this to the collection as well. if userID > 0 { ownerID.Int64 = userID ownerID.Valid = true if collID > 0 { ownerCollID.Int64 = collID ownerCollID.Valid = true var slugVal string if post.Title != nil && *post.Title != "" { slugVal = getSlug(*post.Title, post.Language.String) if slugVal == "" { slugVal = getSlug(*post.Content, post.Language.String) } } else { slugVal = getSlug(*post.Content, post.Language.String) } if slugVal == "" { slugVal = friendlyID } slug = sql.NullString{slugVal, true} } } created := time.Now() if db.driverName == driverSQLite { // SQLite stores datetimes in UTC, so convert time.Now() to it here created = created.UTC() } if post.Created != nil { created, err = time.Parse("2006-01-02T15:04:05Z", *post.Created) if err != nil { log.Error("Unable to parse Created time '%s': %v", *post.Created, err) created = time.Now() if db.driverName == driverSQLite { // SQLite stores datetimes in UTC, so convert time.Now() to it here created = created.UTC() } } } stmt, err := db.Prepare("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, privacy, owner_id, collection_id, created, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + db.now() + ", ?)") if err != nil { return nil, err } defer stmt.Close() _, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0) if err != nil { if db.isDuplicateKeyErr(err) { // Duplicate entry error; try a new slug // TODO: make this a little more robust slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true} _, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0) if err != nil { return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err)) } } else { return nil, handleFailedPostInsert(err) } } // TODO: return Created field in proper format return &Post{ ID: friendlyID, Slug: null.NewString(slug.String, slug.Valid), Font: appearance, Language: zero.NewString(post.Language.String, post.Language.Valid), RTL: zero.NewBool(post.IsRTL.Bool, post.IsRTL.Valid), OwnerID: null.NewInt(userID, true), CollectionID: null.NewInt(userID, true), Created: created.Truncate(time.Second).UTC(), Updated: time.Now().Truncate(time.Second).UTC(), Title: zero.NewString(*(post.Title), true), Content: *(post.Content), }, nil } // UpdateOwnedPost updates an existing post with only the given fields in the // supplied AuthenticatedPost. func (db *datastore) UpdateOwnedPost(post *AuthenticatedPost, userID int64) error { params := []interface{}{} var queryUpdates, sep, authCondition string if post.Slug != nil && *post.Slug != "" { queryUpdates += sep + "slug = ?" sep = ", " params = append(params, getSlug(*post.Slug, "")) } if post.Content != nil { queryUpdates += sep + "content = ?" sep = ", " params = append(params, post.Content) } if post.Title != nil { queryUpdates += sep + "title = ?" sep = ", " params = append(params, post.Title) } if post.Language.Valid { queryUpdates += sep + "language = ?" sep = ", " params = append(params, post.Language.String) } if post.IsRTL.Valid { queryUpdates += sep + "rtl = ?" sep = ", " params = append(params, post.IsRTL.Bool) } if post.Font != "" { queryUpdates += sep + "text_appearance = ?" sep = ", " params = append(params, post.Font) } if post.Created != nil { createTime, err := time.Parse(postMetaDateFormat, *post.Created) if err != nil { log.Error("Unable to parse Created date: %v", err) return fmt.Errorf("That's the incorrect format for Created date.") } queryUpdates += sep + "created = ?" sep = ", " params = append(params, createTime) } // WHERE parameters... // id = ? params = append(params, post.ID) // AND owner_id = ? authCondition = "(owner_id = ?)" params = append(params, userID) if queryUpdates == "" { return ErrPostNoUpdatableVals } queryUpdates += sep + "updated = " + db.now() res, err := db.Exec("UPDATE posts SET "+queryUpdates+" WHERE id = ? AND "+authCondition, params...) if err != nil { log.Error("Unable to update owned post: %v", err) return err } rowsAffected, _ := res.RowsAffected() if rowsAffected == 0 { // Show the correct error message if nothing was updated var dummy int err := db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND "+authCondition, post.ID, params[len(params)-1]).Scan(&dummy) switch { case err == sql.ErrNoRows: return ErrUnauthorizedEditPost case err != nil: log.Error("Failed selecting from posts: %v", err) } return nil } return nil } func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Collection, error) { c := &Collection{} // FIXME: change Collection to reflect database values. Add helper functions to get actual values var styleSheet, script, format zero.String row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value) err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &c.Views) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} case err != nil: log.Error("Failed selecting from collections: %v", err) return nil, err } c.StyleSheet = styleSheet.String c.Script = script.String c.Format = format.String c.Public = c.IsPublic() c.db = db return c, nil } func (db *datastore) GetCollection(alias string) (*Collection, error) { return db.GetCollectionBy("alias = ?", alias) } func (db *datastore) GetCollectionForPad(alias string) (*Collection, error) { c := &Collection{Alias: alias} row := db.QueryRow("SELECT id, alias, title, description, privacy FROM collections WHERE alias = ?", alias) err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility) switch { case err == sql.ErrNoRows: return c, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} case err != nil: log.Error("Failed selecting from collections: %v", err) return c, ErrInternalGeneral } c.Public = c.IsPublic() return c, nil } func (db *datastore) GetCollectionByID(id int64) (*Collection, error) { return db.GetCollectionBy("id = ?", id) } func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) { return db.GetCollectionBy("host = ?", host) } func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) error { q := query.NewUpdate(). SetStringPtr(c.Title, "title"). SetStringPtr(c.Description, "description"). SetNullString(c.StyleSheet, "style_sheet"). SetNullString(c.Script, "script") if c.Format != nil { cf := &CollectionFormat{Format: c.Format.String} if cf.Valid() { q.SetNullString(c.Format, "format") } } var updatePass bool if c.Visibility != nil && (collVisibility(*c.Visibility)&CollProtected == 0 || c.Pass != "") { q.SetIntPtr(c.Visibility, "privacy") if c.Pass != "" { updatePass = true } } // WHERE values q.Where("alias = ? AND owner_id = ?", alias, c.OwnerID) if q.Updates == "" { return ErrPostNoUpdatableVals } // Find any current domain var collID int64 var rowsAffected int64 var changed bool var res sql.Result err := db.QueryRow("SELECT id FROM collections WHERE alias = ?", alias).Scan(&collID) if err != nil { log.Error("Failed selecting from collections: %v. Some things won't work.", err) } // Update MathJax value if c.MathJax { if db.driverName == driverSQLite { _, err = db.Exec("INSERT OR REPLACE INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", collID, "render_mathjax", "1") } else { _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", collID, "render_mathjax", "1", "1") } if err != nil { log.Error("Unable to insert render_mathjax value: %v", err) return err } } else { _, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "render_mathjax") if err != nil { log.Error("Unable to delete render_mathjax value: %v", err) return err } } // Update rest of the collection data res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) if err != nil { log.Error("Unable to update collection: %v", err) return err } rowsAffected, _ = res.RowsAffected() if !changed || rowsAffected == 0 { // Show the correct error message if nothing was updated var dummy int err := db.QueryRow("SELECT 1 FROM collections WHERE alias = ? AND owner_id = ?", alias, c.OwnerID).Scan(&dummy) switch { case err == sql.ErrNoRows: return ErrUnauthorizedEditPost case err != nil: log.Error("Failed selecting from collections: %v", err) } if !updatePass { return nil } } if updatePass { hashedPass, err := auth.HashPass([]byte(c.Pass)) if err != nil { log.Error("Unable to create hash: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } if db.driverName == driverSQLite { _, err = db.Exec("INSERT OR REPLACE INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?)", alias, hashedPass) } else { _, err = db.Exec("INSERT INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?) "+db.upsert("collection_id")+" password = ?", alias, hashedPass, hashedPass) } if err != nil { return err } } return nil } const postCols = "id, slug, text_appearance, language, rtl, privacy, owner_id, collection_id, pinned_position, created, updated, view_count, title, content" // getEditablePost returns a PublicPost with the given ID only if the given // edit token is valid for the post. func (db *datastore) GetEditablePost(id, editToken string) (*PublicPost, error) { // FIXME: code duplicated from getPost() // TODO: add slight logic difference to getPost / one func var ownerName sql.NullString p := &Post{} row := db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE id = ? LIMIT 1", id) err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName) switch { case err == sql.ErrNoRows: return nil, ErrPostNotFound case err != nil: log.Error("Failed selecting from collections: %v", err) return nil, err } if p.Content == "" && p.Title.String == "" { return nil, ErrPostUnpublished } res := p.processPost() if ownerName.Valid { res.Owner = &PublicUser{Username: ownerName.String} } return &res, nil } func (db *datastore) PostIDExists(id string) bool { var dummy bool err := db.QueryRow("SELECT 1 FROM posts WHERE id = ?", id).Scan(&dummy) return err == nil && dummy } // GetPost gets a public-facing post object from the database. If collectionID // is > 0, the post will be retrieved by slug and collection ID, rather than // post ID. // TODO: break this into two functions: // - GetPost(id string) // - GetCollectionPost(slug string, collectionID int64) func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error) { var ownerName sql.NullString p := &Post{} var row *sql.Row var where string params := []interface{}{id} if collectionID > 0 { where = "slug = ? AND collection_id = ?" params = append(params, collectionID) } else { where = "id = ?" } row = db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE "+where+" LIMIT 1", params...) err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName) switch { case err == sql.ErrNoRows: if collectionID > 0 { return nil, ErrCollectionPageNotFound } return nil, ErrPostNotFound case err != nil: log.Error("Failed selecting from collections: %v", err) return nil, err } if p.Content == "" && p.Title.String == "" { return nil, ErrPostUnpublished } res := p.processPost() if ownerName.Valid { res.Owner = &PublicUser{Username: ownerName.String} } return &res, nil } // TODO: don't duplicate getPost() functionality func (db *datastore) GetOwnedPost(id string, ownerID int64) (*PublicPost, error) { p := &Post{} var row *sql.Row where := "id = ? AND owner_id = ?" params := []interface{}{id, ownerID} row = db.QueryRow("SELECT "+postCols+" FROM posts WHERE "+where+" LIMIT 1", params...) err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content) switch { case err == sql.ErrNoRows: return nil, ErrPostNotFound case err != nil: log.Error("Failed selecting from collections: %v", err) return nil, err } if p.Content == "" && p.Title.String == "" { return nil, ErrPostUnpublished } res := p.processPost() return &res, nil } func (db *datastore) GetPostProperty(id string, collectionID int64, property string) (interface{}, error) { propSelects := map[string]string{ "views": "view_count AS views", } selectQuery, ok := propSelects[property] if !ok { return nil, impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid property: %s.", property)} } var res interface{} var row *sql.Row if collectionID != 0 { row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE slug = ? AND collection_id = ? LIMIT 1", id, collectionID) } else { row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE id = ? LIMIT 1", id) } err := row.Scan(&res) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "Post not found."} case err != nil: log.Error("Failed selecting post: %v", err) return nil, err } return res, nil } // GetPostsCount modifies the CollectionObj to include the correct number of // standard (non-pinned) posts. It will return future posts if `includeFuture` // is true. func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) { var count int64 timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition, c.ID).Scan(&count) switch { case err == sql.ErrNoRows: c.TotalPosts = 0 case err != nil: log.Error("Failed selecting from collections: %v", err) c.TotalPosts = 0 } c.TotalPosts = int(count) } // GetPosts retrieves all posts for the given Collection. // It will return future posts if `includeFuture` is true. // It will include only standard (non-pinned) posts unless `includePinned` is true. // TODO: change includeFuture to isOwner, since that's how it's used func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) { collID := c.ID cf := c.NewFormat() order := "DESC" if cf.Ascending() && !forceRecentFirst { order = "ASC" } pagePosts := cf.PostsPerPage() start := page*pagePosts - pagePosts if page == 0 { start = 0 pagePosts = 1000 } limitStr := "" if page > 0 { limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) } timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } pinnedCondition := "" if !includePinned { pinnedCondition = "AND pinned_position IS NULL" } rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? "+pinnedCondition+" "+timeCondition+" ORDER BY created "+order+limitStr, collID) if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."} } defer rows.Close() // TODO: extract this common row scanning logic for queries using `postCols` posts := []PublicPost{} for rows.Next() { p := &Post{} err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content) if err != nil { log.Error("Failed scanning row: %v", err) break } p.extractData() p.formatContent(cfg, c, includeFuture) posts = append(posts, p.processPost()) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return &posts, nil } // GetPostsTagged retrieves all posts on the given Collection that contain the // given tag. // It will return future posts if `includeFuture` is true. // TODO: change includeFuture to isOwner, since that's how it's used func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) { collID := c.ID cf := c.NewFormat() order := "DESC" if cf.Ascending() { order = "ASC" } pagePosts := cf.PostsPerPage() start := page*pagePosts - pagePosts if page == 0 { start = 0 pagePosts = 1000 } limitStr := "" if page > 0 { limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts) } timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } var rows *sql.Rows var err error if db.driverName == driverSQLite { rows, err = db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) regexp ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, `.*#`+strings.ToLower(tag)+`\b.*`) } else { rows, err = db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, "#"+strings.ToLower(tag)+"[[:>:]]") } if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."} } defer rows.Close() // TODO: extract this common row scanning logic for queries using `postCols` posts := []PublicPost{} for rows.Next() { p := &Post{} err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content) if err != nil { log.Error("Failed scanning row: %v", err) break } p.extractData() p.formatContent(cfg, c, includeFuture) posts = append(posts, p.processPost()) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return &posts, nil } func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) { rows, err := db.Query("SELECT actor_id, inbox, shared_inbox FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID) if err != nil { log.Error("Failed selecting from followers: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."} } defer rows.Close() followers := []RemoteUser{} for rows.Next() { f := RemoteUser{} err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox) followers = append(followers, f) } return &followers, nil } // CanCollect returns whether or not the given user can add the given post to a // collection. This is true when a post is already owned by the user. // NOTE: this is currently only used to potentially add owned posts to a // collection. This has the SIDE EFFECT of also generating a slug for the post. // FIXME: make this side effect more explicit (or extract it) func (db *datastore) CanCollect(cpr *ClaimPostRequest, userID int64) bool { var title, content string var lang sql.NullString err := db.QueryRow("SELECT title, content, language FROM posts WHERE id = ? AND owner_id = ?", cpr.ID, userID).Scan(&title, &content, &lang) switch { case err == sql.ErrNoRows: return false case err != nil: log.Error("Failed on post CanCollect(%s, %d): %v", cpr.ID, userID, err) return false } // Since we have the post content and the post is collectable, generate the // post's slug now. cpr.Slug = getSlugFromPost(title, content, lang.String) return true } func (db *datastore) AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) { qRes, err := db.Exec(query, params...) if err != nil { if db.isDuplicateKeyErr(err) && slugIdx > -1 { s := id.GenSafeUniqueSlug(p.Slug) if s == p.Slug { // Sanity check to prevent infinite recursion return qRes, fmt.Errorf("GenSafeUniqueSlug generated nothing unique: %s", s) } p.Slug = s params[slugIdx] = p.Slug return db.AttemptClaim(p, query, params, slugIdx) } return qRes, fmt.Errorf("attemptClaim: %s", err) } return qRes, nil } func (db *datastore) DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error) { postClaimReqs := map[string]bool{} res := []ClaimPostResult{} for i := range postIDs { postID := postIDs[i] r := ClaimPostResult{Code: 0, ErrorMessage: ""} // Perform post validation if postID == "" { r.ErrorMessage = "Missing post ID. " } if _, ok := postClaimReqs[postID]; ok { r.Code = 429 r.ErrorMessage = "You've already tried anonymizing this post." r.ID = postID res = append(res, r) continue } postClaimReqs[postID] = true var err error // Get full post information to return var fullPost *PublicPost fullPost, err = db.GetPost(postID, 0) if err != nil { if err, ok := err.(impart.HTTPError); ok { r.Code = err.Status r.ErrorMessage = err.Message r.ID = postID res = append(res, r) continue } else { log.Error("Error getting post in dispersePosts: %v", err) } } if fullPost.OwnerID.Int64 != userID { r.Code = http.StatusConflict r.ErrorMessage = "Post is already owned by someone else." r.ID = postID res = append(res, r) continue } var qRes sql.Result var query string var params []interface{} // Do AND owner_id = ? for sanity. // This should've been caught and returned with a good error message // just above. query = "UPDATE posts SET collection_id = NULL WHERE id = ? AND owner_id = ?" params = []interface{}{postID, userID} qRes, err = db.Exec(query, params...) if err != nil { r.Code = http.StatusInternalServerError r.ErrorMessage = "A glitch happened on our end." r.ID = postID res = append(res, r) log.Error("dispersePosts (post %s): %v", postID, err) continue } // Post was successfully dispersed r.Code = http.StatusOK r.Post = fullPost rowsAffected, _ := qRes.RowsAffected() if rowsAffected == 0 { // This was already claimed, but return 200 r.Code = http.StatusOK } res = append(res, r) } return &res, nil } func (db *datastore) ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) { postClaimReqs := map[string]bool{} res := []ClaimPostResult{} postCollAlias := collAlias for i := range *posts { p := (*posts)[i] if &p == nil { continue } r := ClaimPostResult{Code: 0, ErrorMessage: ""} // Perform post validation if p.ID == "" { r.ErrorMessage = "Missing post ID `id`. " } if _, ok := postClaimReqs[p.ID]; ok { r.Code = 429 r.ErrorMessage = "You've already tried claiming this post." r.ID = p.ID res = append(res, r) continue } postClaimReqs[p.ID] = true canCollect := db.CanCollect(&p, userID) if !canCollect && p.Token == "" { // TODO: ensure post isn't owned by anyone else when a valid modify // token is given. r.ErrorMessage += "Missing post Edit Token `token`." } if r.ErrorMessage != "" { // Post validate failed r.Code = http.StatusBadRequest r.ID = p.ID res = append(res, r) continue } var err error var qRes sql.Result var query string var params []interface{} var slugIdx int = -1 var coll *Collection if collAlias == "" { // Posts are being claimed at /posts/claim, not // /collections/{alias}/collect, so use given individual collection // to associate post with. postCollAlias = p.CollectionAlias } if postCollAlias != "" { // Associate this post with a collection if p.CreateCollection { // This is a new collection // TODO: consider removing this. This seriously complicates this // method and adds another (unnecessary?) logic path. coll, err = db.CreateCollection(cfg, postCollAlias, "", userID) if err != nil { if err, ok := err.(impart.HTTPError); ok { r.Code = err.Status r.ErrorMessage = err.Message } else { r.Code = http.StatusInternalServerError r.ErrorMessage = "Unknown error occurred creating collection" } r.ID = p.ID res = append(res, r) continue } } else { // Attempt to add to existing collection coll, err = db.GetCollection(postCollAlias) if err != nil { if err, ok := err.(impart.HTTPError); ok { if err.Status == http.StatusNotFound { // Show obfuscated "forbidden" response, as if attempting to add to an // unowned blog. r.Code = ErrForbiddenCollection.Status r.ErrorMessage = ErrForbiddenCollection.Message } else { r.Code = err.Status r.ErrorMessage = err.Message } } else { r.Code = http.StatusInternalServerError r.ErrorMessage = "Unknown error occurred claiming post with collection" } r.ID = p.ID res = append(res, r) continue } if coll.OwnerID != userID { r.Code = ErrForbiddenCollection.Status r.ErrorMessage = ErrForbiddenCollection.Message r.ID = p.ID res = append(res, r) continue } } if p.Slug == "" { p.Slug = p.ID } if canCollect { // User already owns this post, so just add it to the given // collection. query = "UPDATE posts SET collection_id = ?, slug = ? WHERE id = ? AND owner_id = ?" params = []interface{}{coll.ID, p.Slug, p.ID, userID} slugIdx = 1 } else { query = "UPDATE posts SET owner_id = ?, collection_id = ?, slug = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL" params = []interface{}{userID, coll.ID, p.Slug, p.ID, p.Token} slugIdx = 2 } } else { query = "UPDATE posts SET owner_id = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL" params = []interface{}{userID, p.ID, p.Token} } qRes, err = db.AttemptClaim(&p, query, params, slugIdx) if err != nil { r.Code = http.StatusInternalServerError r.ErrorMessage = "An unknown error occurred." r.ID = p.ID res = append(res, r) log.Error("claimPosts (post %s): %v", p.ID, err) continue } // Get full post information to return var fullPost *PublicPost if p.Token != "" { fullPost, err = db.GetEditablePost(p.ID, p.Token) } else { fullPost, err = db.GetPost(p.ID, 0) } if err != nil { if err, ok := err.(impart.HTTPError); ok { r.Code = err.Status r.ErrorMessage = err.Message r.ID = p.ID res = append(res, r) continue } } if fullPost.OwnerID.Int64 != userID { r.Code = http.StatusConflict r.ErrorMessage = "Post is already owned by someone else." r.ID = p.ID res = append(res, r) continue } // Post was successfully claimed r.Code = http.StatusOK r.Post = fullPost if coll != nil { r.Post.Collection = &CollectionObj{Collection: *coll} } rowsAffected, _ := qRes.RowsAffected() if rowsAffected == 0 { // This was already claimed, but return 200 r.Code = http.StatusOK } res = append(res, r) } return &res, nil } func (db *datastore) UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error { if pos <= 0 || pos > 20 { pos = db.GetLastPinnedPostPos(collID) + 1 if pos == -1 { pos = 1 } } var err error if pinned { _, err = db.Exec("UPDATE posts SET pinned_position = ? WHERE id = ?", pos, postID) } else { _, err = db.Exec("UPDATE posts SET pinned_position = NULL WHERE id = ?", postID) } if err != nil { log.Error("Unable to update pinned post: %v", err) return err } return nil } func (db *datastore) GetLastPinnedPostPos(collID int64) int64 { var lastPos sql.NullInt64 err := db.QueryRow("SELECT MAX(pinned_position) FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL", collID).Scan(&lastPos) switch { case err == sql.ErrNoRows: return -1 case err != nil: log.Error("Failed selecting from posts: %v", err) return -1 } if !lastPos.Valid { return -1 } return lastPos.Int64 } func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[]PublicPost, error) { // FIXME: sqlite-backed instances don't include ellipsis on truncated titles timeCondition := "" if !includeFuture { timeCondition = "AND created <= " + db.now() } rows, err := db.Query("SELECT id, slug, title, "+db.clip("content", 80)+", pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL "+timeCondition+" ORDER BY pinned_position ASC", coll.ID) if err != nil { log.Error("Failed selecting pinned posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve pinned posts."} } defer rows.Close() posts := []PublicPost{} for rows.Next() { p := &Post{} err = rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Content, &p.PinnedPosition) if err != nil { log.Error("Failed scanning row: %v", err) break } p.extractData() pp := p.processPost() pp.Collection = coll posts = append(posts, pp) } return &posts, nil } func (db *datastore) GetCollections(u *User, hostName string) (*[]Collection, error) { rows, err := db.Query("SELECT id, alias, title, description, privacy, view_count FROM collections WHERE owner_id = ? ORDER BY id ASC", u.ID) if err != nil { log.Error("Failed selecting from collections: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user collections."} } defer rows.Close() colls := []Collection{} for rows.Next() { c := Collection{} err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views) if err != nil { log.Error("Failed scanning row: %v", err) break } c.hostName = hostName c.URL = c.CanonicalURL() c.Public = c.IsPublic() colls = append(colls, c) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return &colls, nil } func (db *datastore) GetPublishableCollections(u *User, hostName string) (*[]Collection, error) { c, err := db.GetCollections(u, hostName) if err != nil { return nil, err } if len(*c) == 0 { return nil, impart.HTTPError{http.StatusInternalServerError, "You don't seem to have any blogs; they might've moved to another account. Try logging out and logging into your other account."} } return c, nil } func (db *datastore) GetMeStats(u *User) userMeStats { s := userMeStats{} // User counts colls, _ := db.GetUserCollectionCount(u.ID) s.TotalCollections = colls var articles, collPosts uint64 err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NULL", u.ID).Scan(&articles) if err != nil && err != sql.ErrNoRows { log.Error("Couldn't get articles count for user %d: %v", u.ID, err) } s.TotalArticles = articles err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NOT NULL", u.ID).Scan(&collPosts) if err != nil && err != sql.ErrNoRows { log.Error("Couldn't get coll posts count for user %d: %v", u.ID, err) } s.CollectionPosts = collPosts return s } func (db *datastore) GetTotalCollections() (collCount int64, err error) { - err = db.QueryRow(`SELECT COUNT(*) FROM collections`).Scan(&collCount) + err = db.QueryRow(` + SELECT COUNT(*) + FROM collections c + LEFT JOIN users u ON u.id = c.owner_id + WHERE u.status = 0`).Scan(&collCount) if err != nil { log.Error("Unable to fetch collections count: %v", err) } return } func (db *datastore) GetTotalPosts() (postCount int64, err error) { - err = db.QueryRow(`SELECT COUNT(*) FROM posts`).Scan(&postCount) + err = db.QueryRow(` + SELECT COUNT(*) + FROM posts p + LEFT JOIN users u ON u.id = p.owner_id + WHERE u.status = 0`).Scan(&postCount) if err != nil { log.Error("Unable to fetch posts count: %v", err) } return } func (db *datastore) GetTopPosts(u *User, alias string) (*[]PublicPost, error) { params := []interface{}{u.ID} where := "" if alias != "" { where = " AND alias = ?" params = append(params, alias) } rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON p.collection_id = c.id WHERE p.owner_id = ?"+where+" ORDER BY p.view_count DESC, created DESC LIMIT 25", params...) if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user top posts."} } defer rows.Close() posts := []PublicPost{} var gotErr bool for rows.Next() { p := Post{} c := Collection{} var alias, title, description sql.NullString var views sql.NullInt64 err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &alias, &title, &description, &views) if err != nil { log.Error("Failed scanning User.getPosts() row: %v", err) gotErr = true break } p.extractData() pubPost := p.processPost() if alias.Valid && alias.String != "" { c.Alias = alias.String c.Title = title.String c.Description = description.String c.Views = views.Int64 pubPost.Collection = &CollectionObj{Collection: c} } posts = append(posts, pubPost) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } if gotErr && len(posts) == 0 { // There were a lot of errors return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."} } return &posts, nil } func (db *datastore) GetAnonymousPosts(u *User) (*[]PublicPost, error) { rows, err := db.Query("SELECT id, view_count, title, created, updated, content FROM posts WHERE owner_id = ? AND collection_id IS NULL ORDER BY created DESC", u.ID) if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user anonymous posts."} } defer rows.Close() posts := []PublicPost{} for rows.Next() { p := Post{} err = rows.Scan(&p.ID, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content) if err != nil { log.Error("Failed scanning row: %v", err) break } p.extractData() posts = append(posts, p.processPost()) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return &posts, nil } func (db *datastore) GetUserPosts(u *User) (*[]PublicPost, error) { rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, p.created, p.updated, p.content, p.text_appearance, p.language, p.rtl, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON collection_id = c.id WHERE p.owner_id = ? ORDER BY created ASC", u.ID) if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."} } defer rows.Close() posts := []PublicPost{} var gotErr bool for rows.Next() { p := Post{} c := Collection{} var alias, title, description sql.NullString var views sql.NullInt64 err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content, &p.Font, &p.Language, &p.RTL, &alias, &title, &description, &views) if err != nil { log.Error("Failed scanning User.getPosts() row: %v", err) gotErr = true break } p.extractData() pubPost := p.processPost() if alias.Valid && alias.String != "" { c.Alias = alias.String c.Title = title.String c.Description = description.String c.Views = views.Int64 pubPost.Collection = &CollectionObj{Collection: c} } posts = append(posts, pubPost) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } if gotErr && len(posts) == 0 { // There were a lot of errors return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."} } return &posts, nil } func (db *datastore) GetUserPostsCount(userID int64) int64 { var count int64 err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ?", userID).Scan(&count) switch { case err == sql.ErrNoRows: return 0 case err != nil: log.Error("Failed selecting posts count for user %d: %v", userID, err) return 0 } return count } // ChangeSettings takes a User and applies the changes in the given // userSettings, MODIFYING THE USER with successful changes. func (db *datastore) ChangeSettings(app *App, u *User, s *userSettings) error { var errPass error q := query.NewUpdate() // Update email if given if s.Email != "" { encEmail, err := data.Encrypt(app.keys.EmailKey, s.Email) if err != nil { log.Error("Couldn't encrypt email %s: %s\n", s.Email, err) return impart.HTTPError{http.StatusInternalServerError, "Unable to encrypt email address."} } q.SetBytes(encEmail, "email") // Update the email if something goes awry updating the password defer func() { if errPass != nil { db.UpdateEncryptedUserEmail(u.ID, encEmail) } }() u.Email = zero.StringFrom(s.Email) } // Update username if given var newUsername string if s.Username != "" { var ie *impart.HTTPError newUsername, ie = getValidUsername(app, s.Username, u.Username) if ie != nil { // Username is invalid return *ie } if !author.IsValidUsername(app.cfg, newUsername) { // Ensure the username is syntactically correct. return impart.HTTPError{http.StatusPreconditionFailed, "Username isn't valid."} } t, err := db.Begin() if err != nil { log.Error("Couldn't start username change transaction: %v", err) return err } _, err = t.Exec("UPDATE users SET username = ? WHERE id = ?", newUsername, u.ID) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { return impart.HTTPError{http.StatusConflict, "Username is already taken."} } log.Error("Unable to update users table: %v", err) return ErrInternalGeneral } _, err = t.Exec("UPDATE collections SET alias = ? WHERE alias = ? AND owner_id = ?", newUsername, u.Username, u.ID) if err != nil { t.Rollback() if db.isDuplicateKeyErr(err) { return impart.HTTPError{http.StatusConflict, "Username is already taken."} } log.Error("Unable to update collection: %v", err) return ErrInternalGeneral } // Keep track of name changes for redirection db.RemoveCollectionRedirect(t, newUsername) _, err = t.Exec("UPDATE collectionredirects SET new_alias = ? WHERE new_alias = ?", newUsername, u.Username) if err != nil { log.Error("Unable to update collectionredirects: %v", err) } _, err = t.Exec("INSERT INTO collectionredirects (prev_alias, new_alias) VALUES (?, ?)", u.Username, newUsername) if err != nil { log.Error("Unable to add new collectionredirect: %v", err) } err = t.Commit() if err != nil { t.Rollback() log.Error("Rolling back after Commit(): %v\n", err) return err } u.Username = newUsername } // Update passphrase if given if s.NewPass != "" { // Check if user has already set a password var err error u.HasPass, err = db.IsUserPassSet(u.ID) if err != nil { errPass = impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data."} return errPass } if u.HasPass { // Check if currently-set password is correct hashedPass := u.HashedPass if len(hashedPass) == 0 { authUser, err := db.GetUserForAuthByID(u.ID) if err != nil { errPass = err return errPass } hashedPass = authUser.HashedPass } if !auth.Authenticated(hashedPass, []byte(s.OldPass)) { errPass = impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} return errPass } } hashedPass, err := auth.HashPass([]byte(s.NewPass)) if err != nil { errPass = impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} return errPass } q.SetBytes(hashedPass, "password") } // WHERE values q.Append(u.ID) if q.Updates == "" { if s.Username == "" { return ErrPostNoUpdatableVals } // Nothing to update except username. That was successful, so return now. return nil } res, err := db.Exec("UPDATE users SET "+q.Updates+" WHERE id = ?", q.Params...) if err != nil { log.Error("Unable to update collection: %v", err) return err } rowsAffected, _ := res.RowsAffected() if rowsAffected == 0 { // Show the correct error message if nothing was updated var dummy int err := db.QueryRow("SELECT 1 FROM users WHERE id = ?", u.ID).Scan(&dummy) switch { case err == sql.ErrNoRows: return ErrUnauthorizedGeneral case err != nil: log.Error("Failed selecting from users: %v", err) } return nil } if s.NewPass != "" && !u.HasPass { u.HasPass = true } return nil } func (db *datastore) ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error { var dbPass []byte err := db.QueryRow("SELECT password FROM users WHERE id = ?", userID).Scan(&dbPass) switch { case err == sql.ErrNoRows: return ErrUserNotFound case err != nil: log.Error("Couldn't SELECT user password for change: %v", err) return err } if !sudo && !auth.Authenticated(dbPass, []byte(curPass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } _, err = db.Exec("UPDATE users SET password = ? WHERE id = ?", hashedPass, userID) if err != nil { log.Error("Could not update passphrase: %v", err) return err } return nil } func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error { _, err := t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ?", alias) if err != nil { log.Error("Unable to delete from collectionredirects: %v", err) return err } return nil } func (db *datastore) GetCollectionRedirect(alias string) (new string) { row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias) err := row.Scan(&new) if err != nil && err != sql.ErrNoRows { log.Error("Failed selecting from collectionredirects: %v", err) } return } func (db *datastore) DeleteCollection(alias string, userID int64) error { c := &Collection{Alias: alias} var username string row := db.QueryRow("SELECT username FROM users WHERE id = ?", userID) err := row.Scan(&username) if err != nil { return err } // Ensure user isn't deleting their main blog if alias == username { return impart.HTTPError{http.StatusForbidden, "You cannot currently delete your primary blog."} } row = db.QueryRow("SELECT id FROM collections WHERE alias = ? AND owner_id = ?", alias, userID) err = row.Scan(&c.ID) switch { case err == sql.ErrNoRows: return impart.HTTPError{http.StatusNotFound, "Collection doesn't exist or you're not allowed to delete it."} case err != nil: log.Error("Failed selecting from collections: %v", err) return ErrInternalGeneral } t, err := db.Begin() if err != nil { return err } // Float all collection's posts _, err = t.Exec("UPDATE posts SET collection_id = NULL WHERE collection_id = ? AND owner_id = ?", c.ID, userID) if err != nil { t.Rollback() return err } // Remove redirects to or from this collection _, err = t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ? OR new_alias = ?", alias, alias) if err != nil { t.Rollback() return err } // Remove any optional collection password _, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID) if err != nil { t.Rollback() return err } // Finally, delete collection itself _, err = t.Exec("DELETE FROM collections WHERE id = ?", c.ID) if err != nil { t.Rollback() return err } err = t.Commit() if err != nil { t.Rollback() return err } return nil } func (db *datastore) IsCollectionAttributeOn(id int64, attr string) bool { var v string err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v) switch { case err == sql.ErrNoRows: return false case err != nil: log.Error("Couldn't SELECT value in isCollectionAttributeOn for attribute '%s': %v", attr, err) return false } return v == "1" } func (db *datastore) CollectionHasAttribute(id int64, attr string) bool { var dummy string err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&dummy) switch { case err == sql.ErrNoRows: return false case err != nil: log.Error("Couldn't SELECT value in collectionHasAttribute for attribute '%s': %v", attr, err) return false } return true } func (db *datastore) DeleteAccount(userID int64) (l *string, err error) { debug := "" l = &debug t, err := db.Begin() if err != nil { stringLogln(l, "Unable to begin: %v", err) return } // Get all collections rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID) if err != nil { t.Rollback() stringLogln(l, "Unable to get collections: %v", err) return } defer rows.Close() colls := []Collection{} var c Collection for rows.Next() { err = rows.Scan(&c.ID, &c.Alias) if err != nil { t.Rollback() stringLogln(l, "Unable to scan collection cols: %v", err) return } colls = append(colls, c) } var res sql.Result for _, c := range colls { // TODO: user deleteCollection() func // Delete tokens res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID) if err != nil { t.Rollback() stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err) return } rs, _ := res.RowsAffected() stringLogln(l, "Deleted %d for %s from collectionattributes", rs, c.Alias) // Remove any optional collection password res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID) if err != nil { t.Rollback() stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err) return } rs, _ = res.RowsAffected() stringLogln(l, "Deleted %d for %s from collectionpasswords", rs, c.Alias) // Remove redirects to this collection res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias) if err != nil { t.Rollback() stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err) return } rs, _ = res.RowsAffected() stringLogln(l, "Deleted %d for %s from collectionredirects", rs, c.Alias) } // Delete collections res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID) if err != nil { t.Rollback() stringLogln(l, "Unable to delete collections: %v", err) return } rs, _ := res.RowsAffected() stringLogln(l, "Deleted %d from collections", rs) // Delete tokens res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID) if err != nil { t.Rollback() stringLogln(l, "Unable to delete access tokens: %v", err) return } rs, _ = res.RowsAffected() stringLogln(l, "Deleted %d from accesstokens", rs) // Delete posts res, err = t.Exec("DELETE FROM posts WHERE owner_id = ?", userID) if err != nil { t.Rollback() stringLogln(l, "Unable to delete posts: %v", err) return } rs, _ = res.RowsAffected() stringLogln(l, "Deleted %d from posts", rs) res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID) if err != nil { t.Rollback() stringLogln(l, "Unable to delete attributes: %v", err) return } rs, _ = res.RowsAffected() stringLogln(l, "Deleted %d from userattributes", rs) res, err = t.Exec("DELETE FROM users WHERE id = ?", userID) if err != nil { t.Rollback() stringLogln(l, "Unable to delete user: %v", err) return } rs, _ = res.RowsAffected() stringLogln(l, "Deleted %d from users", rs) err = t.Commit() if err != nil { t.Rollback() stringLogln(l, "Unable to commit: %v", err) return } return } func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) { var pub, priv []byte err := db.QueryRow("SELECT public_key, private_key FROM collectionkeys WHERE collection_id = ?", collectionID).Scan(&pub, &priv) switch { case err == sql.ErrNoRows: // Generate keys pub, priv = activitypub.GenerateKeys() _, err = db.Exec("INSERT INTO collectionkeys (collection_id, public_key, private_key) VALUES (?, ?, ?)", collectionID, pub, priv) if err != nil { log.Error("Unable to INSERT new activitypub keypair: %v", err) return nil, nil } case err != nil: log.Error("Couldn't SELECT collectionkeys: %v", err) return nil, nil } return pub, priv } func (db *datastore) CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error { _, err := db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, ?, "+db.now()+", ?, 0)", id, userID, maxUses, expires) return err } func (db *datastore) GetUserInvites(userID int64) (*[]Invite, error) { rows, err := db.Query("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE owner_id = ? ORDER BY created DESC", userID) if err != nil { log.Error("Failed selecting from userinvites: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user invites."} } defer rows.Close() is := []Invite{} for rows.Next() { i := Invite{} err = rows.Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive) is = append(is, i) } return &is, nil } func (db *datastore) GetUserInvite(id string) (*Invite, error) { var i Invite err := db.QueryRow("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE id = ?", id).Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."} case err != nil: log.Error("Failed selecting invite: %v", err) return nil, err } return &i, nil } // IsUsersInvite returns true if the user with ID created the invite with code // and an error other than sql no rows, if any. Will return false in the event // of an error. func (db *datastore) IsUsersInvite(code string, userID int64) (bool, error) { var id string err := db.QueryRow("SELECT id FROM userinvites WHERE id = ? AND owner_id = ?", code, userID).Scan(&id) if err != nil && err != sql.ErrNoRows { log.Error("Failed selecting invite: %v", err) return false, err } return id != "", nil } func (db *datastore) GetUsersInvitedCount(id string) int64 { var count int64 err := db.QueryRow("SELECT COUNT(*) FROM usersinvited WHERE invite_id = ?", id).Scan(&count) switch { case err == sql.ErrNoRows: return 0 case err != nil: log.Error("Failed selecting users invited count: %v", err) return 0 } return count } func (db *datastore) CreateInvitedUser(inviteID string, userID int64) error { _, err := db.Exec("INSERT INTO usersinvited (invite_id, user_id) VALUES (?, ?)", inviteID, userID) return err } func (db *datastore) GetInstancePages() ([]*instanceContent, error) { return db.GetAllDynamicContent("page") } func (db *datastore) GetAllDynamicContent(t string) ([]*instanceContent, error) { where := "" params := []interface{}{} if t != "" { where = " WHERE content_type = ?" params = append(params, t) } rows, err := db.Query("SELECT id, title, content, updated, content_type FROM appcontent"+where, params...) if err != nil { log.Error("Failed selecting from appcontent: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve instance pages."} } defer rows.Close() pages := []*instanceContent{} for rows.Next() { c := &instanceContent{} err = rows.Scan(&c.ID, &c.Title, &c.Content, &c.Updated, &c.Type) if err != nil { log.Error("Failed scanning row: %v", err) break } pages = append(pages, c) } err = rows.Err() if err != nil { log.Error("Error after Next() on rows: %v", err) } return pages, nil } func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) { c := &instanceContent{ ID: id, } err := db.QueryRow("SELECT title, content, updated, content_type FROM appcontent WHERE id = ?", id).Scan(&c.Title, &c.Content, &c.Updated, &c.Type) switch { case err == sql.ErrNoRows: return nil, nil case err != nil: log.Error("Couldn't SELECT FROM appcontent for id '%s': %v", id, err) return nil, err } return c, nil } func (db *datastore) UpdateDynamicContent(id, title, content, contentType string) error { var err error if db.driverName == driverSQLite { _, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?)", id, title, content, contentType) } else { _, err = db.Exec("INSERT INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?) "+db.upsert("id")+" title = ?, content = ?, updated = "+db.now(), id, title, content, contentType, title, content) } if err != nil { log.Error("Unable to INSERT appcontent for '%s': %v", id, err) } return err } func (db *datastore) GetAllUsers(page uint) (*[]User, error) { limitStr := fmt.Sprintf("0, %d", adminUsersPerPage) if page > 1 { limitStr = fmt.Sprintf("%d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage) } - rows, err := db.Query("SELECT id, username, created FROM users ORDER BY created DESC LIMIT " + limitStr) + rows, err := db.Query("SELECT id, username, created, status FROM users ORDER BY created DESC LIMIT " + limitStr) if err != nil { - log.Error("Failed selecting from posts: %v", err) - return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."} + log.Error("Failed selecting from users: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve all users."} } defer rows.Close() users := []User{} for rows.Next() { u := User{} - err = rows.Scan(&u.ID, &u.Username, &u.Created) + err = rows.Scan(&u.ID, &u.Username, &u.Created, &u.Status) if err != nil { log.Error("Failed scanning GetAllUsers() row: %v", err) break } users = append(users, u) } return &users, nil } func (db *datastore) GetAllUsersCount() int64 { var count int64 err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) switch { case err == sql.ErrNoRows: return 0 case err != nil: log.Error("Failed selecting all users count: %v", err) return 0 } return count } func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) { var t time.Time err := db.QueryRow("SELECT created FROM posts WHERE owner_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t) switch { case err == sql.ErrNoRows: return nil, nil case err != nil: log.Error("Failed selecting last post time from posts: %v", err) return nil, err } return &t, nil } +// SetUserStatus changes a user's status in the database. see Users.UserStatus +func (db *datastore) SetUserStatus(id int64, status UserStatus) error { + _, err := db.Exec("UPDATE users SET status = ? WHERE id = ?", status, id) + if err != nil { + return fmt.Errorf("failed to update user status: %v", err) + } + return nil +} + func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) { var t time.Time err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t) switch { case err == sql.ErrNoRows: return nil, nil case err != nil: log.Error("Failed selecting last post time from posts: %v", err) return nil, err } return &t, nil } // DatabaseInitialized returns whether or not the current datastore has been // initialized with the correct schema. // Currently, it checks to see if the `users` table exists. func (db *datastore) DatabaseInitialized() bool { var dummy string var err error if db.driverName == driverSQLite { err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'").Scan(&dummy) } else { err = db.QueryRow("SHOW TABLES LIKE 'users'").Scan(&dummy) } switch { case err == sql.ErrNoRows: return false case err != nil: log.Error("Couldn't SHOW TABLES: %v", err) return false } return true } func stringLogln(log *string, s string, v ...interface{}) { *log += fmt.Sprintf(s+"\n", v...) } func handleFailedPostInsert(err error) error { log.Error("Couldn't insert into posts: %v", err) return err } diff --git a/errors.go b/errors.go index 0092b7f..c0d435c 100644 --- a/errors.go +++ b/errors.go @@ -1,54 +1,57 @@ /* * Copyright © 2018 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( - "github.com/writeas/impart" "net/http" + + "github.com/writeas/impart" ) // Commonly returned HTTP errors var ( ErrBadFormData = impart.HTTPError{http.StatusBadRequest, "Expected valid form data."} ErrBadJSON = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON object."} ErrBadJSONArray = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON array."} ErrBadAccessToken = impart.HTTPError{http.StatusUnauthorized, "Invalid access token."} ErrNoAccessToken = impart.HTTPError{http.StatusBadRequest, "Authorization token required."} ErrNotLoggedIn = impart.HTTPError{http.StatusUnauthorized, "Not logged in."} ErrForbiddenCollection = impart.HTTPError{http.StatusForbidden, "You don't have permission to add to this collection."} ErrForbiddenEditPost = impart.HTTPError{http.StatusForbidden, "You don't have permission to update this post."} ErrUnauthorizedEditPost = impart.HTTPError{http.StatusUnauthorized, "Invalid editing credentials."} ErrUnauthorizedGeneral = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to do that."} ErrBadRequestedType = impart.HTTPError{http.StatusNotAcceptable, "Bad requested Content-Type."} ErrCollectionUnauthorizedRead = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to access this collection."} ErrNoPublishableContent = impart.HTTPError{http.StatusBadRequest, "Supply something to publish."} ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."} ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."} ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."} ErrPostNotFound = impart.HTTPError{Status: http.StatusNotFound, Message: "Post not found."} ErrPostBanned = impart.HTTPError{Status: http.StatusGone, Message: "Post removed."} ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."} ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."} ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} + + ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is silenced."} ) // Post operation errors var ( ErrPostNoUpdatableVals = impart.HTTPError{http.StatusBadRequest, "Supply some properties to update."} ) diff --git a/feed.go b/feed.go index 32feb82..44bb331 100644 --- a/feed.go +++ b/feed.go @@ -1,111 +1,121 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "fmt" + "net/http" + "time" + . "github.com/gorilla/feeds" "github.com/gorilla/mux" stripmd "github.com/writeas/go-strip-markdown" "github.com/writeas/web-core/log" - "net/http" - "time" ) func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { alias := collectionAliasFromReq(req) // Display collection if this is a collection var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return nil } + + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("view feed: get user: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrCollectionNotFound + } c.hostName = app.cfg.App.Host if c.IsPrivate() || c.IsProtected() { return ErrCollectionNotFound } // Fetch extra data about the Collection // TODO: refactor out this logic, shared in collection.go:fetchCollection() coll := &DisplayCollection{CollectionObj: &CollectionObj{Collection: *c}} if c.PublicOwner { u, err := app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } else { coll.Owner = u } } tag := mux.Vars(req)["tag"] if tag != "" { coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false) } else { coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false) } author := "" if coll.Owner != nil { author = coll.Owner.Username } collectionTitle := coll.DisplayTitle() if tag != "" { collectionTitle = tag + " — " + collectionTitle } baseUrl := coll.CanonicalURL() basePermalinkUrl := baseUrl siteURL := baseUrl if tag != "" { siteURL += "tag:" + tag } feed := &Feed{ Title: collectionTitle, Link: &Link{Href: siteURL}, Description: coll.Description, Author: &Author{author, ""}, Created: time.Now(), } var title, permalink string for _, p := range *coll.Posts { title = p.PlainDisplayTitle() permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String) feed.Items = append(feed.Items, &Item{ Id: fmt.Sprintf("%s%s", basePermalinkUrl, p.Slug.String), Title: title, Link: &Link{Href: permalink}, Description: "", Content: applyMarkdown([]byte(p.Content), "", app.cfg), Author: &Author{author, ""}, Created: p.Created, Updated: p.Updated, }) } rss, err := feed.ToRss() if err != nil { return err } fmt.Fprint(w, rss) return nil } diff --git a/invites.go b/invites.go index 4e1f5fa..1dba7bd 100644 --- a/invites.go +++ b/invites.go @@ -1,175 +1,179 @@ /* * Copyright © 2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "html/template" "net/http" "strconv" "time" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/nerds/store" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/page" ) type Invite struct { ID string MaxUses sql.NullInt64 Created time.Time Expires *time.Time Inactive bool uses int64 } func (i Invite) Uses() int64 { return i.uses } func (i Invite) Expired() bool { return i.Expires != nil && i.Expires.Before(time.Now()) } func (i Invite) ExpiresFriendly() string { return i.Expires.Format("January 2, 2006, 3:04 PM") } func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Request) error { // Don't show page if instance doesn't allow it if !(app.cfg.App.UserInvites != "" && (u.IsAdmin() || app.cfg.App.UserInvites != "admin")) { return impart.HTTPError{http.StatusNotFound, ""} } f, _ := getSessionFlashes(app, w, r, nil) p := struct { *UserPage Invites *[]Invite }{ UserPage: NewUserPage(app, r, u, "Invite People", f), } var err error p.Invites, err = app.db.GetUserInvites(u.ID) if err != nil { return err } for i := range *p.Invites { (*p.Invites)[i].uses = app.db.GetUsersInvitedCount((*p.Invites)[i].ID) } showUserPage(w, "invite", p) return nil } func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Request) error { muVal := r.FormValue("uses") expVal := r.FormValue("expires") + if u.IsSilenced() { + return ErrUserSuspended + } + var err error var maxUses int if muVal != "0" { maxUses, err = strconv.Atoi(muVal) if err != nil { return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'max_uses'"} } } var expDate *time.Time var expires int if expVal != "0" { expires, err = strconv.Atoi(expVal) if err != nil { return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'expires'"} } ed := time.Now().Add(time.Duration(expires) * time.Minute) expDate = &ed } inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6) err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate) if err != nil { return err } return impart.HTTPError{http.StatusFound, "/me/invites"} } func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error { inviteCode := mux.Vars(r)["code"] i, err := app.db.GetUserInvite(inviteCode) if err != nil { return err } expired := i.Expired() if !expired && i.MaxUses.Valid && i.MaxUses.Int64 > 0 { // Invite has a max-use number, so check if we're past that limit i.uses = app.db.GetUsersInvitedCount(inviteCode) expired = i.uses >= i.MaxUses.Int64 } if u := getUserSession(app, r); u != nil { // check if invite belongs to another user // error can be ignored as not important in this case if ownInvite, _ := app.db.IsUsersInvite(inviteCode, u.ID); !ownInvite { addSessionFlash(app, w, r, "You're already registered and logged in.", nil) // show homepage return impart.HTTPError{http.StatusFound, "/me/settings"} } // show invite instructions p := struct { *UserPage Invite *Invite Expired bool }{ UserPage: NewUserPage(app, r, u, "Invite to "+app.cfg.App.SiteName, nil), Invite: i, Expired: expired, } showUserPage(w, "invite-help", p) return nil } p := struct { page.StaticPage Error string Flashes []template.HTML Invite string }{ StaticPage: pageForReq(app, r), Invite: inviteCode, } if expired { p.Error = "This invite link has expired." } // Get error messages session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session in handleViewInvite; ignoring: %v", err) } flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } // Show landing page return renderPage(w, "signup.tmpl", p) } diff --git a/less/core.less b/less/core.less index f4332a9..8844c84 100644 --- a/less/core.less +++ b/less/core.less @@ -1,1513 +1,1520 @@ @primary: rgb(114, 120, 191); @secondary: rgb(114, 191, 133); @subheaders: #444; @headerTextColor: black; @sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif; @serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif; @monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace; @dangerCol: #e21d27; @errUrgentCol: #ecc63c; @proSelectedCol: #71D571; @textLinkColor: rgb(0, 0, 238); body { font-family: @serifFont; font-size-adjust: 0.5; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: white; color: #111; h1, header h2 { a { color: @headerTextColor; .transition-duration(0.2s); &:hover { color: #303030; text-decoration: none; } } } h1, h2, h3 { line-height: 1.2; } &#post article, &#collection article p, &#subpage article p { display: block; unicode-bidi: embed; white-space: pre; } &#post { #wrapper, pre { max-width: 40em; margin: 0 auto; a:hover { text-decoration: underline; } } blockquote { p + p { margin: -2em 0 0.5em; } } article { margin-bottom: 2em !important; h1, h2, h3, h4, h5, h6, p, ul, ol, code { display: inline; margin: 0; } hr + p, ol, ul { display: block; margin-top: -1rem; margin-bottom: -1rem; } ol, ul { margin: 2rem 0 -1rem; ol, ul { margin: 1.25rem 0 -0.5rem; } } li { margin-top: -0.5rem; margin-bottom: -0.5rem; } h2#title { .article-title; } h1 { font-size: 1.5em; } h2 { font-size: 1.17em; } } header { nav { span, a { &.pinned { &.selected { font-weight: bold; } &+.views { margin-left: 2em; } } } } } .owner-visible { display: none; } } &#post, &#collection, &#subpage { code { .article-code; } img, video, audio { max-width: 100%; } audio { width: 100%; white-space: initial; } pre { .code-block; code { background: transparent; border: 0; padding: 0; font-size: 1em; white-space: pre-wrap; /* CSS 3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -pre-wrap; /* Opera 4-6 */ white-space: -o-pre-wrap; /* Opera 7 */ word-wrap: break-word; /* Internet Explorer 5.5+ */ } } blockquote { .article-blockquote; } article { hr { margin-top: 0; margin-bottom: 0; } p.badge { background-color: #aaa; display: inline-block; padding: 0.25em 0.5em; margin: 0; float: right; color: white; .rounded(.25em); } } header { nav { span, a { &.pinned { &+.pinned { margin-left: 1.5em; } } } } } footer { nav { a { margin-top: 0; } } } } &#collection { #welcome, .access { margin: 0 auto; max-width: 35em; h2 { font-weight: normal; margin-bottom: 1em; } p { font-size: 1.2em; line-height: 1.6; } } .access { margin: 8em auto; text-align: center; h2, ul.errors { font-size: 1.2em; margin-bottom: 1.5em !important; } } header { padding: 0 1em; text-align: center; max-width: 50em; margin: 3em auto 4em; .writeas-prefix { a { color: #aaa; } display: block; margin-bottom: 0.5em; } nav { display: block; margin: 1em 0; a:first-child { margin: 0; } } } nav#manage { position: absolute; top: 1em; left: 1.5em; li a.write { font-family: @serifFont; padding-top: 0.2em; padding-bottom: 0.2em; } } pre { line-height: 1.5; } } &#subpage { #wrapper { h1 { font-size: 2.5em; letter-spacing: -2px; padding: 0 2rem 2rem; } } } &#post { pre { font-size: 0.75em; } } &#collection, &#subpage { #wrapper { margin-left: auto; margin-right: auto; article { margin-bottom: 4em; &:hover { .hidden { .opacity(1); } } } h2 { margin-top: 0em; margin-bottom: 0.25em; &+time { display: block; margin-top: 0.25em; margin-bottom: 0.25em; } } time { font-size: 1.1em; &+p { margin-top: 0.25em; } } footer { text-align: left; padding: 0; } } #paging { overflow: visible; padding: 1em 6em 0; } a.read-more { color: #666; } } &#me #official-writing { h2 { font-weight: normal; a { font-size: 0.6em; margin-left: 1em; } a[name] { margin-left: 0; } a:link, a:visited { color: @textLinkColor; } a:hover { text-decoration: underline; } } } &#promo { div.heading { margin: 8em 0; } div.heading, div.attention-form { h1 { font-size: 3.5em; } input { padding-left: 0.75em; padding-right: 0.75em; &[type=email] { max-width: 16em; } &[type=submit] { padding-left: 1.5em; padding-right: 1.5em; } } } h2 { margin-bottom: 0; font-size: 1.8em; font-weight: normal; span.write-as { color: black; } &.soon { color: lighten(@subheaders, 50%); span { &.write-as { color: lighten(#000, 50%); } &.note { color: lighten(#333, 50%); font-variant: small-caps; margin-left: 0.5em; } } } } .half-col a { margin-left: 1em; margin-right: 1em; } } nav#top-nav { display: inline; position: absolute; top: 1.5em; right: 1.5em; font-size: 0.95rem; font-family: @sansFont; text-transform: uppercase; a { color: #777; } a + a { margin-left: 1em; } } footer { nav, ul { a { display: inline-block; margin-top: 0.8em; .transition-duration(0.1s); text-decoration: none; + a { margin-left: 0.8em; } &:link, &:visited { color: #999; } &:hover { color: #666; text-decoration: none; } } } a.home { &:link, &:visited { color: #333; } font-weight: bold; text-decoration: none; &:hover { color: #000; } } ul { list-style: none; text-align: left; padding-left: 0 !important; margin-left: 0 !important; .icons img { height: 16px; width: 16px; fill: #999; } } } } nav#full-nav { margin: 0; .left-side { display: inline-block; a:first-child { margin-left: 0; } } .right-side { float: right; } } nav#full-nav a.simple-btn, .tool button { font-family: @sansFont; border: 1px solid #ccc !important; padding: .5rem 1rem; margin: 0; .rounded(.25em); text-decoration: none; } .post-title { a { &:link { color: #333; } &:visited { color: #444; } } time, time a:link, time a:visited, &+.time { color: #999; } } .hidden { -moz-transition-property: opacity; -webkit-transition-property: opacity; -o-transition-property: opacity; transition-property: opacity; .transition-duration(0.4s); .opacity(0); } a { text-decoration: none; &:hover { text-decoration: underline; } &.subdued { color: #999; &:hover { border-bottom: 1px solid #999; text-decoration: none; } } &.danger { color: @dangerCol; font-size: 0.86em; } &.simple-cta { text-decoration: none; border-bottom: 1px solid #ccc; color: #333; padding-bottom: 2px; &:hover { text-decoration: none; } } &.action-btn { font-family: @sansFont; text-transform: uppercase; .rounded(.25em); background-color: red; color: white; font-weight: bold; padding: 0.5em 0.75em; &:hover { background-color: lighten(#f00, 5%); text-decoration: none; } } &.hashtag:hover { text-decoration: none; span + span { text-decoration: underline; } } &.hashtag { span:first-child { color: #999; margin-right: 0.1em; font-size: 0.86em; text-decoration: none; } } } abbr { border-bottom: 1px dotted #999; text-decoration: none; cursor: help; } body#collection article p, body#subpage article p { .article-p; } -pre, body#post article, body#collection article, body#subpage article, body#subpage #wrapper h1 { +pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 { max-width: 40rem; margin: 0 auto; } +#collection header .alert, #post .alert, #subpage .alert { + margin-bottom: 1em; + p { + text-align: left; + line-height: 1.4; + } +} textarea, pre, body#post article, body#collection article p { &.norm, &.sans, &.wrap { line-height: 1.4em; white-space: pre-wrap; /* CSS 3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -pre-wrap; /* Opera 4-6 */ white-space: -o-pre-wrap; /* Opera 7 */ word-wrap: break-word; /* Internet Explorer 5.5+ */ } } textarea, pre, body#post article, body#collection article, body#subpage article, span, .font { &.norm { font-family: @serifFont; } &.sans { font-family: @sansFont; } &.mono, &.wrap, &.code { font-family: @monoFont; } &.mono, &.code { max-width: none !important; } } textarea { &.section { border: 1px solid #ccc; padding: 0.65em 0.75em; .rounded(.25em); &.codable { height: 12em; resize: vertical; } } } .ace_editor { height: 12em; border: 1px solid #333; max-width: initial; width: 100%; font-size: 0.86em !important; border: 1px solid #ccc; padding: 0.65em 0.75em; margin: 0; .rounded(.25em); } p { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; &.intro { font-size: 1.25em; text-align: center; } &.upgrade-prompt { font-size: 0.9em; color: #444; } &.text-cta { font-size: 1.2em; text-align: center; margin-bottom: 0.5em; &+ p { text-align: center; font-size: 0.7em; margin-top: 0; color: #666; } } &.error { font-style: italic; color: @errUrgentCol; } &.headeresque { font-size: 2em; } } table.classy { width: 95%; border-collapse: collapse; margin-bottom: 2em; tr + tr { border-top: 1px solid #ccc; } th { text-transform: uppercase; font-weight: normal; font-size: 95%; font-family: @sansFont; padding: 1rem 0.75rem; text-align: center; } td { height: 3.5rem; } p { margin-top: 0 !important; margin-bottom: 0 !important; } &.export { .disabled { color: #999; } .disabled, a { text-transform: lowercase; } } } body#collection article, body#subpage article { padding-top: 0; padding-bottom: 0; .book { h2 { font-size: 1.4em; } a.hidden.action { color: #666; float: right; font-size: 1em; margin-left: 1em; margin-bottom: 1em; } } } body#post article { p.badge { font-size: 0.9em; } } article { h2.post-title a[rel=nofollow]::after { content: '\a0 \2934'; } } table.downloads { width: 100%; td { text-align: center; } img.os { width: 48px; vertical-align: middle; margin-bottom: 6px; } } select.inputform, textarea.inputform { border: 1px solid #999; } input, button, select.inputform, textarea.inputform { padding: 0.5em; font-family: @serifFont; font-size: 100%; .rounded(.25em); &[type=submit], &.submit { border: 1px solid @primary; background: @primary; color: white; .transition(0.2s); &:hover { background-color: lighten(@primary, 3%); } &:disabled { cursor: default; background-color: desaturate(@primary, 100%) !important; border-color: desaturate(@primary, 100%) !important; } } &.error[type=text], textarea.error { -webkit-transition: all 0.30s ease-in-out; -moz-transition: all 0.30s ease-in-out; -ms-transition: all 0.30s ease-in-out; -o-transition: all 0.30s ease-in-out; outline: none; } &.danger { border: 1px solid @dangerCol; background: @dangerCol; color: white; &:hover { background-color: lighten(@dangerCol, 3%); } } &.error[type=text]:focus, textarea.error:focus { box-shadow: 0 0 5px @errUrgentCol; border: 1px solid @errUrgentCol; } } div.flat-select { display: inline-block; position: relative; select { border: 0; background: 0; -webkit-appearance: none; -moz-appearance: none; appearance: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; opacity: 0; } &.action { &:hover { label { text-decoration: underline; } } label, select { cursor: pointer; } } } input { &.underline{ border: none; border-bottom: 1px solid #ccc; padding: 0 .2em .2em; font-size: 0.9em; color: #333; } &.inline { padding: 0.2rem 0.2rem; margin-left: 0; font-size: 1em; border: 0 !important; border-bottom: 1px solid #999 !important; width: 7em; .rounded(0); } &[type=tel], &[type=text], &[type=email], &[type=password] { border: 1px solid #999; } &.boxy { border: 1px solid #999 !important; } } #beta, .content-container { max-width: 50em; margin: 0 auto 3em; font-size: 1.2em; &.tight { max-width: 30em; } &.snug { max-width: 40em; } &.regular { font-size: 1em; } .app { + .app { margin-top: 1.5em; } h2 { margin-bottom: 0.25em; } p { margin-top: 0.25em; } } h2.intro { font-weight: normal; } p { line-height: 1.4; } li { margin: 0.3em 0; } h2 { &.light { font-weight: normal; } a { .transition-duration(0.2s); -moz-transition-property: color; -webkit-transition-property: color; -o-transition-property: color; transition-property: color; &:link, &:visited, &:hover { color: @subheaders; } &:hover { color: lighten(@subheaders, 10%); text-decoration: none; } } } } .content-container { &#pricing { button { cursor: pointer; color: white; margin-top: 1em; margin-bottom: 1em; padding-left: 1.5em; padding-right: 1.5em; border: 0; background: @primary; .rounded(.25em); .transition(0.2s); &:hover { background-color: lighten(@primary, 5%); } &.unselected { cursor: pointer; } } h2 span { font-weight: normal; } .half { margin: 0 0 1em 0; text-align: center; } } div.features { margin-top: 1.5em; text-align: center; font-size: 0.86em; ul { text-align: left; max-width: 26em; margin-left: auto !important; margin-right: auto !important; li.soon, span.soon { color: lighten(#111, 40%); } } } div.blurbs { >h2 { text-align: center; color: #333; font-weight: normal; } p.price { font-size: 1.2em; margin-bottom: 0; color: #333; margin-top: 0.5em; &+p { margin-top: 0; font-size: 0.8em; } } p.text-cta { font-size: 1em; } } } footer div.blurbs { display: flex; flex-flow: row; flex-wrap: wrap; } div.blurbs { .half, .third, .fourth { font-size: 0.86em; h3 { font-weight: normal; } p, ul { color: #595959; } hr { margin: 1em 0; } } .half { padding: 0 1em 0 0; width: ~"calc(50% - 1em)"; &+.half { padding: 0 0 0 1em; } } .third { padding: 0; width: ~"calc(33% - 1em)"; &+.third { padding: 0 0 0 1em; } } .fourth { flex: 1 1 25%; -webkit-flex: 1 1 25%; h3 { margin-bottom: 0.5em; } ul { margin-top: 0.5em; } } } .contain-me { text-align: left; margin: 0 auto 4em; max-width: 50em; h2 + p, h2 + p + p, p.describe-me { margin-left: 1.5em; margin-right: 1.5em; color: #333; } } footer.contain-me { font-size: 1.1em; } #official-writing, #wrapper { h2, h3, h4 { color: @subheaders; } ul { &.collections { margin-left: 0; li { &.collection { a.title { &:link, &:visited { color: @headerTextColor; } } } a.create { color: #444; } } & + p { margin-top: 2em; margin-left: 1em; } } } } #official-writing, #wrapper { h2 { &.major { color: #222; } &.bugfix { color: #666; } +.android-version { a { color: #999; &:hover { text-decoration: underline; } } } } } li { line-height: 1.4; .item-desc, .prog-lang { font-size: 0.6em; font-family: 'Open Sans', sans-serif; font-weight: bold; margin-left: 0.5em; margin-right: 0.5em; text-transform: uppercase; color: #999; } } .success { color: darken(@proSelectedCol, 20%); } .alert { padding: 1em; margin-bottom: 1.25em; border: 1px solid transparent; .rounded(.25em); &.info { color: #31708f; background-color: #d9edf7; border-color: #bce8f1; } &.success { color: #3c763d; background-color: #dff0d8; border-color: #d6e9c6; } p { margin: 0; &+p { margin-top: 0.5em; } } p.dismiss { font-family: @sansFont; text-align: right; font-size: 0.86em; text-transform: uppercase; } } ul.errors { padding: 0; text-indent: 0; li.urgent { list-style: none; font-style: italic; text-align: center; color: @errUrgentCol; a:link, a:visited { color: purple; } } li.info { list-style: none; font-size: 1.1em; text-align: center; } } body#pad #target a.upgrade-prompt { padding-left: 1em; padding-right: 1em; text-align: center; font-style: italic; color: @primary; } body#pad-sub #posts, .atoms { margin-top: 1.5em; h3 { margin-bottom: 0.25em; &+ h4 { margin-top: 0.25em; margin-bottom: 0.5em; &+ p { margin-top: 0.5em; } } .electron { font-weight: normal; margin-left: 0.5em; } } h3, h4 { a { .transition-duration(0.2s); -moz-transition-property: color; -webkit-transition-property: color; -o-transition-property: color; transition-property: color; } } h4 { font-size: 0.9em; font-weight: normal; } date, .electron { margin-right: 0.5em; } .action { font-size: 1em; } #more-posts p { text-align: center; font-size: 1.1em; } p { font-size: 0.86em; } .error { display: inline-block; font-size: 0.8em; font-style: italic; color: @errUrgentCol; strong { font-style: normal; } } .error + nav { display: inline-block; font-size: 0.8em; margin-left: 1em; a + a { margin-left: 0.75em; } } } h2 { a, time { &+.action { margin-left: 0.5em; } } } .action { font-size: 0.7em; font-weight: normal; font-family: @serifFont; &+ .action { margin-left: 0.5em; } &.new-post { font-weight: bold; } } article.moved { p { font-size: 1.2em; color: #999; } } span.as { .opacity(0.2); font-weight: normal; } span.ras { .opacity(0.6); font-weight: normal; } header { nav { .username { font-size: 2em; font-weight: normal; color: #555; } &#user-nav { margin-left: 0; & > a, .tabs > a { &.selected { cursor: default; font-weight: bold; &:hover { text-decoration: none; } } & + a { margin-left: 2em; } } a { font-size: 1.2em; font-family: @sansFont; span { font-size: 0.7em; color: #999; text-transform: uppercase; margin-left: 0.5em; margin-right: 0.5em; } &.title { font-size: 1.6em; font-family: @serifFont; font-weight: bold; } } nav > ul > li:first-child { &> a { display: inline-block; } img { position: relative; top: -0.5em; right: 0.3em; } } ul ul { font-size: 0.8em; a { padding-top: 0.25em; padding-bottom: 0.25em; } } li { line-height: 1.5; } } &.tabs { margin: 0 0 0 1em; } &+ nav.tabs { margin: 0; } } &.singleuser { margin: 0.5em 0.25em; nav#user-nav { nav > ul > li:first-child { img { top: -0.75em; } } } } .dash-nav { font-weight: bold; } } li#create-collection { display: none; h4 { margin-top: 0px; margin-bottom: 0px; } input[type=submit] { margin-left: 0.5em; } } #collection-options { .option { textarea { font-size: 0.86em; font-family: @monoFont; } .section > p.explain { font-size: 0.8em; } } } .img-placeholder { text-align: center; img { max-width: 100%; } } dl { &.admin-dl-horizontal { dt { font-weight: bolder; width: 360px; } dd { line-height: 1.5; } } } dt { float: left; clear: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } form { dt, dd { padding: 0.5rem 0; } dt { line-height: 1.8; } dd { font-size: 0.86em; line-height: 2; } } div.row { display: flex; align-items: center; > div { flex: 1; } } @media all and (max-width: 450px) { body#post { header { nav { .xtra-feature { display: none; } } } } } @media all and (min-width: 1280px) { body#promo { div.heading { margin: 10em 0; } } } @media all and (min-width: 1600px) { body#promo { div.heading { margin: 14em 0; } } } @media all and (max-width: 900px) { .half.big { padding: 0 !important; width: 100% !important; } .third { padding: 0 !important; float: none; width: 100% !important; p.introduction { font-size: 0.86em; } } div.blurbs { .fourth { flex: 1 1 15em; -webkit-flex: 1 1 15em; } } .blurbs .third, .blurbs .half { p, ul { text-align: left; } } .half-col, .big { float: none; text-align: center; &+.half-col, &+.big { margin-top: 4em !important; margin-left: 0; } } #beta, .content-container { font-size: 1.15em; } } @media all and (max-width: 600px) { div.row { flex-direction: column; } .half { padding: 0 !important; width: 100% !important; } .third { width: 100% !important; float: none; } body#promo { div.heading { margin: 6em 0; } h2 { font-size: 1.6em; } .half-col a + a { margin-left: 1em; } .half-col a.channel { margin-left: auto !important; margin-right: auto !important; } } ul.add-integrations { li { display: list-item; &+ li { margin-left: 0; } } } } @media all and (max-height: 500px) { body#promo { div.heading { margin: 5em 0; } } } @media all and (max-height: 400px) { body#promo { div.heading { margin: 0em 0; } } } /* Smartphones (portrait and landscape) ----------- */ @media only screen and (min-device-width : 320px) and (max-device-width : 480px) { header { .opacity(1); } } /* Smartphones (portrait) ----------- */ @media only screen and (max-width : 320px) { .content-container#pricing { .half { float: none; width: 100%; } } header { .opacity(1); } } /* iPads (portrait and landscape) ----------- */ @media only screen and (min-device-width : 768px) and (max-device-width : 1024px) { header { .opacity(1); } } @media (pointer: coarse) { body footer nav a:not(.pubd) { padding: 0.8em 1em; margin-left: 0; margin-top: 0; } } @media print { h1 { page-break-before: always; } h1, h2, h3, h4, h5, h6 { page-break-after: avoid; } table, figure { page-break-inside: avoid; } header, footer { display: none; } article#post-body { margin-top: 2em; margin-left: 0; margin-right: 0; } hr { border: 1px solid #ccc; } } .code-block { padding: 0; max-width: 100%; margin: 0; background: #f8f8f8; border: 1px solid #ccc; padding: 0.375em 0.625em; font-size: 0.86em; .rounded(.25em); } pre.code-block { overflow-x: auto; } diff --git a/migrations/migrations.go b/migrations/migrations.go index 70e4b7b..0799f8e 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -1,133 +1,135 @@ /* * Copyright © 2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ // Package migrations contains database migrations for WriteFreely package migrations import ( "database/sql" + "github.com/writeas/web-core/log" ) // TODO: refactor to use the datastore struct from writefreely pkg type datastore struct { *sql.DB driverName string } func NewDatastore(db *sql.DB, dn string) *datastore { return &datastore{db, dn} } // TODO: use these consts from writefreely pkg const ( driverMySQL = "mysql" driverSQLite = "sqlite3" ) type Migration interface { Description() string Migrate(db *datastore) error } type migration struct { description string migrate func(db *datastore) error } func New(d string, fn func(db *datastore) error) Migration { return &migration{d, fn} } func (m *migration) Description() string { return m.description } func (m *migration) Migrate(db *datastore) error { return m.migrate(db) } var migrations = []Migration{ New("support user invites", supportUserInvites), // -> V1 (v0.8.0) New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0) + New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0) } // CurrentVer returns the current migration version the application is on func CurrentVer() int { return len(migrations) } func SetInitialMigrations(db *datastore) error { // Included schema files represent changes up to V1, so note that in the database _, err := db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", 1, "") if err != nil { return err } return nil } func Migrate(db *datastore) error { var version int var err error if db.tableExists("appmigrations") { err = db.QueryRow("SELECT MAX(version) FROM appmigrations").Scan(&version) } else { log.Info("Initializing appmigrations table...") version = 0 _, err = db.Exec(`CREATE TABLE appmigrations ( version ` + db.typeInt() + ` NOT NULL, migrated ` + db.typeDateTime() + ` NOT NULL, result ` + db.typeText() + ` NOT NULL ) ` + db.engine() + `;`) if err != nil { return err } } if len(migrations[version:]) > 0 { for i, m := range migrations[version:] { curVer := version + i + 1 log.Info("Migrating to V%d: %s", curVer, m.Description()) err = m.Migrate(db) if err != nil { return err } // Update migrations table _, err = db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", curVer, "") if err != nil { return err } } } else { log.Info("Database up-to-date. No migrations to run.") } return nil } func (db *datastore) tableExists(t string) bool { var dummy string var err error if db.driverName == driverSQLite { err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy) } else { err = db.QueryRow("SHOW TABLES LIKE '" + t + "'").Scan(&dummy) } switch { case err == sql.ErrNoRows: return false case err != nil: log.Error("Couldn't SHOW TABLES: %v", err) return false } return true } diff --git a/migrations/v3.go b/migrations/v3.go new file mode 100644 index 0000000..b5351da --- /dev/null +++ b/migrations/v3.go @@ -0,0 +1,29 @@ +/* + * Copyright © 2019 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportUserStatus(db *datastore) error { + t, err := db.Begin() + + _, err = t.Exec(`ALTER TABLE users ADD COLUMN status ` + db.typeInt() + ` DEFAULT '0' NOT NULL`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/pad.go b/pad.go index 3cb7f37..db356c4 100644 --- a/pad.go +++ b/pad.go @@ -1,173 +1,184 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "net/http" "strings" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/page" ) func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) action := vars["action"] slug := vars["slug"] collAlias := vars["collection"] if app.cfg.App.SingleUser { // TODO: refactor all of this, especially for single-user blogs c, err := app.db.GetCollectionByID(1) if err != nil { return err } collAlias = c.Alias } appData := &struct { page.StaticPage - Post *RawPost - User *User - Blogs *[]Collection + Post *RawPost + User *User + Blogs *[]Collection + Suspended bool Editing bool // True if we're modifying an existing post EditCollection *Collection // Collection of the post we're editing, if any }{ StaticPage: pageForReq(app, r), Post: &RawPost{Font: "norm"}, User: getUserSession(app, r), } var err error if appData.User != nil { appData.Blogs, err = app.db.GetPublishableCollections(appData.User, app.cfg.App.Host) if err != nil { log.Error("Unable to get user's blogs for Pad: %v", err) } + appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + if err != nil { + log.Error("Unable to get users suspension status for Pad: %v", err) + } } padTmpl := app.cfg.App.Editor if templates[padTmpl] == nil { log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl) padTmpl = "pad" } if action == "" && slug == "" { // Not editing any post; simply render the Pad if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil { log.Error("Unable to execute template: %v", err) } return nil } // Retrieve post information for editing appData.Editing = true // Make sure this isn't cached, so user doesn't accidentally lose data w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT") if slug != "" { // TODO: refactor all of this, especially for single-user blogs appData.Post = getRawCollectionPost(app, slug, collAlias) if appData.Post.OwnerID != appData.User.ID { // TODO: add ErrForbiddenEditPost message to flashes return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/edit")]} } appData.EditCollection, err = app.db.GetCollectionForPad(collAlias) if err != nil { return err } } else { // Editing a floating article appData.Post = getRawPost(app, action) appData.Post.Id = action } if appData.Post.Gone { return ErrPostUnpublished } else if appData.Post.Found && appData.Post.Content != "" { // Got the post } else if appData.Post.Found { return ErrPostFetchError } else { return ErrPostNotFound } if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil { log.Error("Unable to execute template: %v", err) } return nil } func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) action := vars["action"] slug := vars["slug"] collAlias := vars["collection"] appData := &struct { page.StaticPage Post *RawPost User *User EditCollection *Collection // Collection of the post we're editing, if any Flashes []string NeedsToken bool + Suspended bool }{ StaticPage: pageForReq(app, r), Post: &RawPost{Font: "norm"}, User: getUserSession(app, r), } var err error + appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + if err != nil { + log.Error("view meta: get user suspended status: %v", err) + return ErrInternalGeneral + } if action == "" && slug == "" { return ErrPostNotFound } // Make sure this isn't cached, so user doesn't accidentally lose data w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Expires", "Thu, 28 Jul 1989 12:00:00 GMT") if slug != "" { appData.Post = getRawCollectionPost(app, slug, collAlias) if appData.Post.OwnerID != appData.User.ID { // TODO: add ErrForbiddenEditPost message to flashes return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/meta")]} } if app.cfg.App.SingleUser { // TODO: optimize this query just like we do in GetCollectionForPad (?) appData.EditCollection, err = app.db.GetCollectionByID(1) } else { appData.EditCollection, err = app.db.GetCollectionForPad(collAlias) } if err != nil { return err } } else { // Editing a floating article appData.Post = getRawPost(app, action) appData.Post.Id = action } appData.NeedsToken = appData.User == nil || appData.User.ID != appData.Post.OwnerID if appData.Post.Gone { return ErrPostUnpublished } else if appData.Post.Found && appData.Post.Content != "" { // Got the post } else if appData.Post.Found { return ErrPostFetchError } else { return ErrPostNotFound } appData.Flashes, _ = getSessionFlashes(app, w, r, nil) if err = templates["edit-meta"].ExecuteTemplate(w, "edit-meta", appData); err != nil { log.Error("Unable to execute template: %v", err) } return nil } diff --git a/posts.go b/posts.go index d004296..2a6fe74 100644 --- a/posts.go +++ b/posts.go @@ -1,1467 +1,1535 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "encoding/json" "fmt" "html/template" "net/http" "regexp" "strings" "time" "github.com/gorilla/mux" "github.com/guregu/null" "github.com/guregu/null/zero" "github.com/kylemcc/twitter-text-go/extract" "github.com/microcosm-cc/bluemonday" stripmd "github.com/writeas/go-strip-markdown" "github.com/writeas/impart" "github.com/writeas/monday" "github.com/writeas/slug" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/bots" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/i18n" "github.com/writeas/web-core/log" "github.com/writeas/web-core/tags" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" "github.com/writeas/writefreely/parse" ) const ( // Post ID length bounds minIDLen = 10 maxIDLen = 10 userPostIDLen = 10 postIDLen = 10 postMetaDateFormat = "2006-01-02 15:04:05" ) type ( AnonymousPost struct { ID string Content string HTMLContent template.HTML Font string Language string Direction string Title string GenTitle string Description string Author string Views int64 IsPlainText bool IsCode bool IsLinkable bool } AuthenticatedPost struct { ID string `json:"id" schema:"id"` Web bool `json:"web" schema:"web"` *SubmittedPost } // SubmittedPost represents a post supplied by a client for publishing or // updating. Since Title and Content can be updated to "", they are // pointers that can be easily tested to detect changes. SubmittedPost struct { Slug *string `json:"slug" schema:"slug"` Title *string `json:"title" schema:"title"` Content *string `json:"body" schema:"body"` Font string `json:"font" schema:"font"` IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"` Language converter.NullJSONString `json:"lang" schema:"lang"` Created *string `json:"created" schema:"created"` } // Post represents a post as found in the database. Post struct { ID string `db:"id" json:"id"` Slug null.String `db:"slug" json:"slug,omitempty"` Font string `db:"text_appearance" json:"appearance"` Language zero.String `db:"language" json:"language"` RTL zero.Bool `db:"rtl" json:"rtl"` Privacy int64 `db:"privacy" json:"-"` OwnerID null.Int `db:"owner_id" json:"-"` CollectionID null.Int `db:"collection_id" json:"-"` PinnedPosition null.Int `db:"pinned_position" json:"-"` Created time.Time `db:"created" json:"created"` Updated time.Time `db:"updated" json:"updated"` ViewCount int64 `db:"view_count" json:"-"` Title zero.String `db:"title" json:"title"` HTMLTitle template.HTML `db:"title" json:"-"` Content string `db:"content" json:"body"` HTMLContent template.HTML `db:"content" json:"-"` HTMLExcerpt template.HTML `db:"content" json:"-"` Tags []string `json:"tags"` Images []string `json:"images,omitempty"` OwnerName string `json:"owner,omitempty"` } // PublicPost holds properties for a publicly returned post, i.e. a post in // a context where the viewer may not be the owner. As such, sensitive // metadata for the post is hidden and properties supporting the display of // the post are added. PublicPost struct { *Post IsSubdomain bool `json:"-"` IsTopLevel bool `json:"-"` DisplayDate string `json:"-"` Views int64 `json:"views"` Owner *PublicUser `json:"-"` IsOwner bool `json:"-"` Collection *CollectionObj `json:"collection,omitempty"` } RawPost struct { Id, Slug string Title string Content string Views int64 Font string Created time.Time IsRTL sql.NullBool Language sql.NullString OwnerID int64 CollectionID sql.NullInt64 Found bool Gone bool } AnonymousAuthPost struct { ID string `json:"id"` Token string `json:"token"` } ClaimPostRequest struct { *AnonymousAuthPost CollectionAlias string `json:"collection"` CreateCollection bool `json:"create_collection"` // Generated properties Slug string `json:"-"` } ClaimPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` Post *PublicPost `json:"post,omitempty"` } ) func (p *Post) Direction() string { if p.RTL.Valid { if p.RTL.Bool { return "rtl" } return "ltr" } return "auto" } // DisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) DisplayTitle() string { if p.Title.String != "" { return p.Title.String } t := friendlyPostTitle(p.Content, p.ID) return t } // PlainDisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) PlainDisplayTitle() string { if t := stripmd.Strip(p.DisplayTitle()); t != "" { return t } return p.ID } // FormattedDisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) FormattedDisplayTitle() template.HTML { if p.HTMLTitle != "" { return p.HTMLTitle } return template.HTML(p.DisplayTitle()) } // Summary gives a shortened summary of the post based on the post's title, // especially for display in a longer list of posts. It extracts a summary for // posts in the Title\n\nBody format, returning nothing if the entire was short // enough that the extracted title == extracted summary. func (p Post) Summary() string { if p.Content == "" { return "" } // Strip out HTML p.Content = bluemonday.StrictPolicy().Sanitize(p.Content) // and Markdown p.Content = stripmd.Strip(p.Content) title := p.Title.String var desc string if title == "" { // No title, so generate one title = friendlyPostTitle(p.Content, p.ID) desc = postDescription(p.Content, title, p.ID) if desc == title { return "" } return desc } return shortPostDescription(p.Content) } // Excerpt shows any text that comes before a (more) tag. // TODO: use HTMLExcerpt in templates instead of this method func (p *Post) Excerpt() template.HTML { return p.HTMLExcerpt } func (p *Post) CreatedDate() string { return p.Created.Format("2006-01-02") } func (p *Post) Created8601() string { return p.Created.Format("2006-01-02T15:04:05Z") } func (p *Post) IsScheduled() bool { return p.Created.After(time.Now()) } func (p *Post) HasTag(tag string) bool { // Regexp looks for tag and has a non-capturing group at the end looking // for the end of the word. // Assisted by: https://stackoverflow.com/a/35192941/1549194 hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content) return hasTag } func (p *Post) HasTitleLink() bool { if p.Title.String == "" { return false } hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String) return hasLink } func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) friendlyID := vars["post"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw() and viewCollectionPost() isJSON := strings.HasSuffix(friendlyID, ".json") isXML := strings.HasSuffix(friendlyID, ".xml") isCSS := strings.HasSuffix(friendlyID, ".css") isMarkdown := strings.HasSuffix(friendlyID, ".md") isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown // Display reserved page if that is requested resource if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok { return handleTemplatedPage(app, w, r, t) } else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" { // Serve static file app.shttp.ServeHTTP(w, r) return nil } // Display collection if this is a collection c, _ := app.db.GetCollection(friendlyID) if c != nil { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)} } // Normalize the URL, redirecting user to consistent post URL if friendlyID != strings.ToLower(friendlyID) { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))} } ext := "" if isRaw { parts := strings.Split(friendlyID, ".") friendlyID = parts[0] if len(parts) > 1 { ext = "." + parts[1] } } var ownerID sql.NullInt64 var title string var content string var font string var language []byte var rtl []byte var views int64 var post *AnonymousPost var found bool var gone bool fixedID := slug.Make(friendlyID) if fixedID != friendlyID { return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)} } err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl) switch { case err == sql.ErrNoRows: found = false // Output the error in the correct format if isJSON { content = "{\"error\": \"Post not found.\"}" } else if isRaw { content = "Post not found." } else { return ErrPostNotFound } case err != nil: found = false log.Error("Post loading err: %s\n", err) return ErrInternalGeneral default: found = true var d string if len(rtl) == 0 { d = "auto" } else if rtl[0] == 49 { // TODO: find a cleaner way to get this (possibly NULL) value d = "rtl" } else { d = "ltr" } generatedTitle := friendlyPostTitle(content, friendlyID) sanitizedContent := content if font != "code" { sanitizedContent = template.HTMLEscapeString(content) } var desc string if title == "" { desc = postDescription(content, title, friendlyID) } else { desc = shortPostDescription(content) } post = &AnonymousPost{ ID: friendlyID, Content: sanitizedContent, Title: title, GenTitle: generatedTitle, Description: desc, Author: "", Font: font, IsPlainText: isRaw, IsCode: font == "code", IsLinkable: font != "code", Views: views, Language: string(language), Direction: d, } if !isRaw { post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg)) } } + suspended, err := app.db.IsUserSuspended(ownerID.Int64) + if err != nil { + log.Error("view post: %v", err) + return ErrInternalGeneral + } + // Check if post has been unpublished if content == "" { gone = true if isJSON { content = "{\"error\": \"Post was unpublished.\"}" } else if isCSS { content = "" } else if isRaw { content = "Post was unpublished." } else { return ErrPostUnpublished } } var u = &User{} if isRaw { contentType := "text/plain" if isJSON { contentType = "application/json" } else if isCSS { contentType = "text/css" } else if isXML { contentType = "application/xml" } else if isMarkdown { contentType = "text/markdown" } w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) if isMarkdown && post.Title != "" { fmt.Fprintf(w, "%s\n", post.Title) for i := 1; i <= len(post.Title); i++ { fmt.Fprintf(w, "=") } fmt.Fprintf(w, "\n\n") } fmt.Fprint(w, content) if !found { return ErrPostNotFound } else if gone { return ErrPostUnpublished } } else { var err error page := struct { *AnonymousPost page.StaticPage - Username string - IsOwner bool - SiteURL string + Username string + IsOwner bool + SiteURL string + Suspended bool }{ AnonymousPost: post, StaticPage: pageForReq(app, r), SiteURL: app.cfg.App.Host, } if u = getUserSession(app, r); u != nil { page.Username = u.Username page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID } + if !page.IsOwner && suspended { + return ErrPostNotFound + } + page.Suspended = suspended err = templates["post"].ExecuteTemplate(w, "post", page) if err != nil { log.Error("Post template execute error: %v", err) } } go func() { if u != nil && ownerID.Valid && ownerID.Int64 == u.ID { // Post is owned by someone; skip view increment since that person is viewing this post. return } // Update stats for non-raw post views if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID) if err != nil { log.Error("Unable to update posts count: %v", err) } } }() return nil } // API v2 funcs // newPost creates a new post with or without an owning Collection. // // Endpoints: // /posts // /posts?collection={alias} // ? /collections/{alias}/posts func newPost(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) collAlias := vars["alias"] if collAlias == "" { collAlias = r.FormValue("collection") } accessToken := r.Header.Get("Authorization") if accessToken == "" { // TODO: remove this accessToken = r.FormValue("access_token") } // FIXME: determine web submission with Content-Type header var u *User var userID int64 = -1 var username string if accessToken == "" { u = getUserSession(app, r) if u != nil { userID = u.ID username = u.Username } } else { userID = app.db.GetUserID(accessToken) } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("new post: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + if userID == -1 { return ErrNotLoggedIn } if accessToken == "" && u == nil && collAlias != "" { return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."} } // Get post data var p *SubmittedPost if reqJSON { decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&p) + err = decoder.Decode(&p) if err != nil { log.Error("Couldn't parse new post JSON request: %v\n", err) return ErrBadJSON } if p.Title == nil { t := "" p.Title = &t } if strings.TrimSpace(*(p.Content)) == "" { return ErrNoPublishableContent } } else { post := r.FormValue("body") appearance := r.FormValue("font") title := r.FormValue("title") rtlValue := r.FormValue("rtl") langValue := r.FormValue("lang") if strings.TrimSpace(post) == "" { return ErrNoPublishableContent } var isRTL, rtlValid bool if rtlValue == "auto" && langValue != "" { isRTL = i18n.LangIsRTL(langValue) rtlValid = true } else { isRTL = rtlValue == "true" rtlValid = rtlValue != "" && langValue != "" } // Create a new post p = &SubmittedPost{ Title: &title, Content: &post, Font: appearance, IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}}, Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}}, } } if !p.isFontValid() { p.Font = "norm" } var newPost *PublicPost = &PublicPost{} var coll *Collection - var err error if accessToken != "" { newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host) } else { //return ErrNotLoggedIn // TODO: verify user is logged in var collID int64 if collAlias != "" { coll, err = app.db.GetCollection(collAlias) if err != nil { return err } coll.hostName = app.cfg.App.Host if coll.OwnerID != u.ID { return ErrForbiddenCollection } collID = coll.ID } // TODO: return PublicPost from createPost newPost.Post, err = app.db.CreatePost(userID, collID, p) } if err != nil { return err } if coll != nil { coll.ForPublic() newPost.Collection = &CollectionObj{Collection: *coll} } newPost.extractData() newPost.OwnerName = username // Write success now response := impart.WriteSuccess(w, newPost, http.StatusCreated) if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { go federatePost(app, newPost, newPost.Collection.ID, false) } return response } func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) postID := vars["post"] p := AuthenticatedPost{ID: postID} var err error if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&p) if err != nil { log.Error("Couldn't parse post update JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse post update form request: %v\n", err) return ErrBadFormData } // Can't decode to a nil SubmittedPost property, so create instance now p.SubmittedPost = &SubmittedPost{} err = app.formDecoder.Decode(&p, r.PostForm) if err != nil { log.Error("Couldn't decode post update form request: %v\n", err) return ErrBadFormData } } if p.Web { p.IsRTL.Valid = true } if p.SubmittedPost == nil { return ErrPostNoUpdatableVals } // Ensure an access token was given accessToken := r.Header.Get("Authorization") // Get user's cookie session if there's no token var u *User //var username string if accessToken == "" { u = getUserSession(app, r) if u != nil { //username = u.Username } } if u == nil && accessToken == "" { return ErrNoAccessToken } // Get user ID from current session or given access token, if one was given. var userID int64 if u != nil { userID = u.ID } else if accessToken != "" { userID, err = AuthenticateUser(app.db, accessToken) if err != nil { return err } } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("existing post: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + // Modify post struct p.ID = postID err = app.db.UpdateOwnedPost(&p, userID) if err != nil { if reqJSON { return err } if err, ok := err.(impart.HTTPError); ok { addSessionFlash(app, w, r, err.Message, nil) } else { addSessionFlash(app, w, r, err.Error(), nil) } } var pRes *PublicPost pRes, err = app.db.GetPost(p.ID, 0) if reqJSON { if err != nil { return err } pRes.extractData() } if pRes.CollectionID.Valid { coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64) if err == nil && !app.cfg.App.Private && app.cfg.App.Federation { coll.hostName = app.cfg.App.Host pRes.Collection = &CollectionObj{Collection: *coll} go federatePost(app, pRes, pRes.Collection.ID, true) } } // Write success now if reqJSON { return impart.WriteSuccess(w, pRes, http.StatusOK) } addSessionFlash(app, w, r, "Changes saved.", nil) collectionAlias := vars["alias"] redirect := "/" + postID + "/meta" if collectionAlias != "" { collPre := "/" + collectionAlias if app.cfg.App.SingleUser { collPre = "" } redirect = collPre + "/" + pRes.Slug.String + "/edit/meta" } else { if app.cfg.App.SingleUser { redirect = "/d" + redirect } } w.Header().Set("Location", redirect) w.WriteHeader(http.StatusFound) return nil } func deletePost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) friendlyID := vars["post"] editToken := r.FormValue("token") var ownerID int64 var u *User accessToken := r.Header.Get("Authorization") if accessToken == "" && editToken == "" { u = getUserSession(app, r) if u == nil { return ErrNoAccessToken } } var res sql.Result var t *sql.Tx var err error var collID sql.NullInt64 var coll *Collection var pp *PublicPost if editToken != "" { // TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries var dummy int64 err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy) switch { case err == sql.ErrNoRows: return impart.HTTPError{http.StatusNotFound, "Post not found."} } err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy) switch { case err == sql.ErrNoRows: // Post already has an owner. This could provide a bad experience // for the user, but it's more important to ensure data isn't lost // unexpectedly. So prevent deletion via token. return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."} } res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken) } else if accessToken != "" || u != nil { // Caller provided some way to authenticate; assume caller expects the // post to be deleted based on a specific post owner, thus we should // return corresponding errors. if accessToken != "" { ownerID = app.db.GetUserID(accessToken) if ownerID == -1 { return ErrBadAccessToken } } else { ownerID = u.ID } // TODO: don't make two queries var realOwnerID sql.NullInt64 err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID) if err != nil { return err } if !collID.Valid { // There's no collection; simply delete the post res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) } else { // Post belongs to a collection; do any additional clean up coll, err = app.db.GetCollectionBy("id = ?", collID.Int64) if err != nil { log.Error("Unable to get collection: %v", err) return err } if app.cfg.App.Federation { // First fetch full post for federation pp, err = app.db.GetOwnedPost(friendlyID, ownerID) if err != nil { log.Error("Unable to get owned post: %v", err) return err } collObj := &CollectionObj{Collection: *coll} pp.Collection = collObj } t, err = app.db.Begin() if err != nil { log.Error("No begin: %v", err) return err } res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) } } else { return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."} } if err != nil { return err } affected, err := res.RowsAffected() if err != nil { if t != nil { t.Rollback() log.Error("Rows affected err! Rolling back") } return err } else if affected == 0 { if t != nil { t.Rollback() log.Error("No rows affected! Rolling back") } return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."} } if t != nil { t.Commit() } if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation { go deleteFederatedPost(app, pp, collID.Int64) } return impart.HTTPError{Status: http.StatusNoContent} } // addPost associates a post with the authenticated user. func addPost(app *App, w http.ResponseWriter, r *http.Request) error { var ownerID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { ownerID = app.db.GetUserID(at) if ownerID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } ownerID = u.ID } + suspended, err := app.db.IsUserSuspended(ownerID) + if err != nil { + log.Error("add post: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + // Parse claimed posts in format: // [{"id": "...", "token": "..."}] var claims *[]ClaimPostRequest decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&claims) + err = decoder.Decode(&claims) if err != nil { return ErrBadJSONArray } vars := mux.Vars(r) collAlias := vars["alias"] // Update all given posts res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims) if err != nil { return err } if !app.cfg.App.Private && app.cfg.App.Federation { for _, pRes := range *res { if pRes.Code != http.StatusOK { continue } if !pRes.Post.Created.After(time.Now()) { pRes.Post.Collection.hostName = app.cfg.App.Host go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false) } } } return impart.WriteSuccess(w, res, http.StatusOK) } func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error { var ownerID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { ownerID = app.db.GetUserID(at) if ownerID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } ownerID = u.ID } // Parse posts in format: // ["..."] var postIDs []string decoder := json.NewDecoder(r.Body) err := decoder.Decode(&postIDs) if err != nil { return ErrBadJSONArray } // Update all given posts res, err := app.db.DispersePosts(ownerID, postIDs) if err != nil { return err } return impart.WriteSuccess(w, res, http.StatusOK) } type ( PinPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` } ) // pinPost pins a post to a blog func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { var userID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { userID = app.db.GetUserID(at) if userID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } userID = u.ID } + suspended, err := app.db.IsUserSuspended(userID) + if err != nil { + log.Error("pin post: %v", err) + return ErrInternalGeneral + } + if suspended { + return ErrUserSuspended + } + // Parse request var posts []struct { ID string `json:"id"` Position int64 `json:"position"` } decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&posts) + err = decoder.Decode(&posts) if err != nil { return ErrBadJSONArray } // Validate data vars := mux.Vars(r) collAlias := vars["alias"] coll, err := app.db.GetCollection(collAlias) if err != nil { return err } if coll.OwnerID != userID { return ErrForbiddenCollection } // Do (un)pinning isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin" res := []PinPostResult{} for _, p := range posts { err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position) ppr := PinPostResult{ID: p.ID} if err != nil { ppr.Code = http.StatusInternalServerError // TODO: set error messsage } else { ppr.Code = http.StatusOK } res = append(res, ppr) } return impart.WriteSuccess(w, res, http.StatusOK) } func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { var collID int64 + var ownerID int64 var coll *Collection var err error vars := mux.Vars(r) if collAlias := vars["alias"]; collAlias != "" { // Fetch collection information, since an alias is provided coll, err = app.db.GetCollection(collAlias) if err != nil { return err } coll.hostName = app.cfg.App.Host _, err = apiCheckCollectionPermissions(app, r, coll) if err != nil { return err } collID = coll.ID + ownerID = coll.OwnerID } p, err := app.db.GetPost(vars["post"], collID) if err != nil { return err } + suspended, err := app.db.IsUserSuspended(ownerID) + if err != nil { + log.Error("fetch post: %v", err) + return ErrInternalGeneral + } + + if suspended { + return ErrPostNotFound + } p.extractData() accept := r.Header.Get("Accept") if strings.Contains(accept, "application/activity+json") { // Fetch information about the collection this belongs to if coll == nil && p.CollectionID.Valid { coll, err = app.db.GetCollectionByID(p.CollectionID.Int64) if err != nil { return err } } if coll == nil { // This is a draft post; 404 for now // TODO: return ActivityObject return impart.HTTPError{http.StatusNotFound, ""} } p.Collection = &CollectionObj{Collection: *coll} po := p.ActivityObject(app.cfg) po.Context = []interface{}{activitystreams.Namespace} return impart.RenderActivityJSON(w, po, http.StatusOK) } return impart.WriteSuccess(w, p, http.StatusOK) } func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"]) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } func (p *Post) processPost() PublicPost { res := &PublicPost{Post: p, Views: 0} res.Views = p.ViewCount // TODO: move to own function loc := monday.FuzzyLocale(p.Language.String) res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc) return *res } func (p *PublicPost) CanonicalURL(hostName string) string { if p.Collection == nil || p.Collection.Alias == "" { return hostName + "/" + p.ID } return p.Collection.CanonicalURL() + p.Slug.String } func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object { o := activitystreams.NewArticleObject() o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.Published = p.Created o.URL = p.CanonicalURL(cfg.App.Host) o.AttributedTo = p.Collection.FederatedAccount() o.CC = []string{ p.Collection.FederatedAccount() + "/followers", } o.Name = p.DisplayTitle() if p.HTMLContent == template.HTML("") { p.formatContent(cfg, false) } o.Content = string(p.HTMLContent) if p.Language.Valid { o.ContentMap = map[string]string{ p.Language.String: string(p.HTMLContent), } } if len(p.Tags) == 0 { o.Tag = []activitystreams.Tag{} } else { var tagBaseURL string if isSingleUser { tagBaseURL = p.Collection.CanonicalURL() + "tag:" } else { if cfg.App.Chorus { tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName) } else { tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias) } } for _, t := range p.Tags { o.Tag = append(o.Tag, activitystreams.Tag{ Type: activitystreams.TagHashtag, HRef: tagBaseURL + t, Name: "#" + t, }) } } return o } // TODO: merge this into getSlugFromPost or phase it out func getSlug(title, lang string) string { return getSlugFromPost("", title, lang) } func getSlugFromPost(title, body, lang string) string { if title == "" { title = postTitle(body, body) } title = parse.PostLede(title, false) // Truncate lede if needed title, _ = parse.TruncToWord(title, 80) var s string if lang != "" && len(lang) == 2 { s = slug.MakeLang(title, lang) } else { s = slug.Make(title) } // Transliteration may cause the slug to expand past the limit, so truncate again s, _ = parse.TruncToWord(s, 80) return strings.TrimFunc(s, func(r rune) bool { // TruncToWord doesn't respect words in a slug, since spaces are replaced // with hyphens. So remove any trailing hyphens. return r == '-' }) } // isFontValid returns whether or not the submitted post's appearance is valid. func (p *SubmittedPost) isFontValid() bool { validFonts := map[string]bool{ "norm": true, "sans": true, "mono": true, "wrap": true, "code": true, } _, valid := validFonts[p.Font] return valid } func getRawPost(app *App, friendlyID string) *RawPost { var content, font, title string var isRTL sql.NullBool var lang sql.NullString var ownerID sql.NullInt64 var created time.Time err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID) switch { case err == sql.ErrNoRows: return &RawPost{Content: "", Found: false, Gone: false} case err != nil: return &RawPost{Content: "", Found: true, Gone: false} } return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""} } // TODO; return a Post! func getRawCollectionPost(app *App, slug, collAlias string) *RawPost { var id, title, content, font string var isRTL sql.NullBool var lang sql.NullString var created time.Time var ownerID null.Int var views int64 var err error if app.cfg.App.SingleUser { err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID) } else { err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID) } switch { case err == sql.ErrNoRows: return &RawPost{Content: "", Found: false, Gone: false} case err != nil: return &RawPost{Content: "", Found: true, Gone: false} } return &RawPost{ Id: id, Slug: slug, Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == "", Views: views, } } func isRaw(r *http.Request) bool { vars := mux.Vars(r) slug := vars["slug"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw in viewCollectionPost() and handleViewPost() isJSON := strings.HasSuffix(slug, ".json") isXML := strings.HasSuffix(slug, ".xml") isMarkdown := strings.HasSuffix(slug, ".md") return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown } func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw() and handleViewPost() isJSON := strings.HasSuffix(slug, ".json") isXML := strings.HasSuffix(slug, ".xml") isMarkdown := strings.HasSuffix(slug, ".md") isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } // Check for hellbanned users u, err := checkUserForCollection(app, cr, r, true) if err != nil { return err } // Normalize the URL, redirecting user to consistent post URL if slug != strings.ToLower(slug) { loc := fmt.Sprintf("/%s", strings.ToLower(slug)) if !app.cfg.App.SingleUser { loc = "/" + cr.alias + loc } return impart.HTTPError{http.StatusMovedPermanently, loc} } // Display collection if this is a collection var c *Collection if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(cr.alias) } if err != nil { if err, ok := err.(impart.HTTPError); ok { if err.Status == http.StatusNotFound { // Redirect if necessary newAlias := app.db.GetCollectionRedirect(cr.alias) if newAlias != "" { return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug} } } } return err } c.hostName = app.cfg.App.Host + suspended, err := app.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("view collection post: %v", err) + return ErrInternalGeneral + } + // Check collection permissions if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) { return ErrPostNotFound } if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) { return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} } cr.isCollOwner = u != nil && c.OwnerID == u.ID if isRaw { slug = strings.Split(slug, ".")[0] } // Fetch extra data about the Collection // TODO: refactor out this logic, shared in collection.go:fetchCollection() coll := &CollectionObj{Collection: *c} owner, err := app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } else { coll.Owner = owner } postFound := true p, err := app.db.GetPost(slug, coll.ID) if err != nil { if err == ErrCollectionPageNotFound { postFound = false if slug == "feed" { // User tried to access blog feed without a trailing slash, and // there's no post with a slug "feed" return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"} } po := &Post{ Slug: null.NewString(slug, true), Font: "norm", Language: zero.NewString("en", true), RTL: zero.NewBool(false, true), Content: `

This page is missing.

Are you sure it was ever here?`, } pp := po.processPost() p = &pp } else { return err } } - p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64 + p.IsOwner = owner != nil && p.OwnerID.Valid && u.ID == p.OwnerID.Int64 p.Collection = coll p.IsTopLevel = app.cfg.App.SingleUser + if !p.IsOwner && suspended { + return ErrPostNotFound + } // Check if post has been unpublished if p.Content == "" && p.Title.String == "" { return impart.HTTPError{http.StatusGone, "Post was unpublished."} } // Serve collection post if isRaw { contentType := "text/plain" if isJSON { contentType = "application/json" } else if isXML { contentType = "application/xml" } else if isMarkdown { contentType = "text/markdown" } w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) if !postFound { w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, "Post not found.") // TODO: return error instead, so status is correctly reflected in logs return nil } if isMarkdown && p.Title.String != "" { fmt.Fprintf(w, "# %s\n\n", p.Title.String) } fmt.Fprint(w, p.Content) } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { if !postFound { return ErrCollectionPageNotFound } p.extractData() ap := p.ActivityObject(app.cfg) ap.Context = []interface{}{activitystreams.Namespace} return impart.RenderActivityJSON(w, ap, http.StatusOK) } else { p.extractData() p.Content = strings.Replace(p.Content, "", "", 1) // TODO: move this to function p.formatContent(app.cfg, cr.isCollOwner) tp := struct { *PublicPost page.StaticPage IsOwner bool IsPinned bool IsCustomDomain bool PinnedPosts *[]PublicPost IsFound bool IsAdmin bool CanInvite bool + Suspended bool }{ PublicPost: p, StaticPage: pageForReq(app, r), IsOwner: cr.isCollOwner, IsCustomDomain: cr.isCustomDomain, IsFound: postFound, + Suspended: suspended, } tp.IsAdmin = u != nil && u.IsAdmin() tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) if !postFound { w.WriteHeader(http.StatusNotFound) } postTmpl := "collection-post" if app.cfg.App.Chorus { postTmpl = "chorus-collection-post" } if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil { log.Error("Error in collection-post template: %v", err) } } go func() { if p.OwnerID.Valid { // Post is owned by someone. Don't update stats if owner is viewing the post. if u != nil && p.OwnerID.Int64 == u.ID { return } } // Update stats for non-raw post views if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID) if err != nil { log.Error("Unable to update posts count: %v", err) } } }() return nil } // TODO: move this to utils after making it more generic func PostsContains(sl *[]PublicPost, s *PublicPost) bool { for _, e := range *sl { if e.ID == s.ID { return true } } return false } func (p *Post) extractData() { p.Tags = tags.Extract(p.Content) p.extractImages() } func (rp *RawPost) UserFacingCreated() string { return rp.Created.Format(postMetaDateFormat) } func (rp *RawPost) Created8601() string { return rp.Created.Format("2006-01-02T15:04:05Z") } var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`) func (p *Post) extractImages() { matches := extract.ExtractUrls(p.Content) urls := map[string]bool{} for i := range matches { u := matches[i].Text if !imageURLRegex.MatchString(u) { continue } urls[u] = true } resURLs := make([]string, 0) for k := range urls { resURLs = append(resURLs, k) } p.Images = resURLs } diff --git a/read.go b/read.go index df24621..d708121 100644 --- a/read.go +++ b/read.go @@ -1,324 +1,326 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "fmt" + "html/template" + "math" + "net/http" + "strconv" + "time" + . "github.com/gorilla/feeds" "github.com/gorilla/mux" stripmd "github.com/writeas/go-strip-markdown" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/web-core/memo" "github.com/writeas/writefreely/page" - "html/template" - "math" - "net/http" - "strconv" - "time" ) const ( tlFeedLimit = 100 tlAPIPageLimit = 10 tlMaxAuthorPosts = 5 tlPostsPerPage = 16 ) type localTimeline struct { m *memo.Memo posts *[]PublicPost // Configuration values postsPerPage int } type readPublication struct { page.StaticPage Posts *[]PublicPost CurrentPage int TotalPages int SelTopic string IsAdmin bool CanInvite bool // Customizable page content ContentTitle string Content template.HTML } func initLocalTimeline(app *App) { app.timeline = &localTimeline{ postsPerPage: tlPostsPerPage, m: memo.New(app.FetchPublicPosts, 10*time.Minute), } } // satisfies memo.Func func (app *App) FetchPublicPosts() (interface{}, error) { // Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated FROM collections c LEFT JOIN posts p ON p.collection_id = c.id - WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) + LEFT JOIN users u ON u.id = p.owner_id + WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0 ORDER BY p.created DESC`) if err != nil { log.Error("Failed selecting from posts: %v", err) return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()} } defer rows.Close() ap := map[string]uint{} posts := []PublicPost{} for rows.Next() { p := &Post{} c := &Collection{} var alias, title sql.NullString err = rows.Scan(&p.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated) if err != nil { log.Error("[READ] Unable to scan row, skipping: %v", err) continue } c.hostName = app.cfg.App.Host isCollectionPost := alias.Valid if isCollectionPost { c.Alias = alias.String if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts { // Don't add post if we've hit the post-per-author limit continue } c.Public = true c.Title = title.String } p.extractData() p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg)) fp := p.processPost() if isCollectionPost { fp.Collection = &CollectionObj{Collection: *c} } posts = append(posts, fp) ap[c.Alias]++ } return posts, nil } func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error { updateTimelineCache(app.timeline) skip, _ := strconv.Atoi(r.FormValue("skip")) posts := []PublicPost{} for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ { posts = append(posts, (*app.timeline.posts)[i]) } return impart.WriteSuccess(w, posts, http.StatusOK) } func viewLocalTimeline(app *App, w http.ResponseWriter, r *http.Request) error { if !app.cfg.App.LocalTimeline { return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} } vars := mux.Vars(r) var p int page := 1 p, _ = strconv.Atoi(vars["page"]) if p > 0 { page = p } return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"]) } func updateTimelineCache(tl *localTimeline) { // Fetch posts if enough time has passed since last cache if tl.posts == nil || tl.m.Invalidate() { log.Info("[READ] Updating post cache") var err error var postsInterfaces interface{} postsInterfaces, err = tl.m.Get() if err != nil { log.Error("[READ] Unable to cache posts: %v", err) } else { castPosts := postsInterfaces.([]PublicPost) tl.posts = &castPosts } } } func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error { updateTimelineCache(app.timeline) pl := len(*(app.timeline.posts)) ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage))) start := 0 if page > 1 { start = app.timeline.postsPerPage * (page - 1) if start > pl { return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)} } } end := app.timeline.postsPerPage * page if end > pl { end = pl } var posts []PublicPost if author != "" { posts = []PublicPost{} for _, p := range *app.timeline.posts { if author == "anonymous" { if p.Collection == nil { posts = append(posts, p) } } else if p.Collection != nil && p.Collection.Alias == author { posts = append(posts, p) } } } else if tag != "" { posts = []PublicPost{} for _, p := range *app.timeline.posts { if p.HasTag(tag) { posts = append(posts, p) } } } else { posts = *app.timeline.posts posts = posts[start:end] } d := &readPublication{ StaticPage: pageForReq(app, r), Posts: &posts, CurrentPage: page, TotalPages: ttlPages, SelTopic: tag, } if app.cfg.App.Chorus { u := getUserSession(app, r) d.IsAdmin = u != nil && u.IsAdmin() d.CanInvite = canUserInvite(app.cfg, d.IsAdmin) } c, err := getReaderSection(app) if err != nil { return err } d.ContentTitle = c.Title.String d.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg)) err = templates["read"].ExecuteTemplate(w, "base", d) if err != nil { log.Error("Unable to render reader: %v", err) fmt.Fprintf(w, ":(") } return nil } // NextPageURL provides a full URL for the next page of collection posts func (c *readPublication) NextPageURL(n int) string { return fmt.Sprintf("/read/p/%d", n+1) } // PrevPageURL provides a full URL for the previous page of collection posts, // returning a /page/N result for pages >1 func (c *readPublication) PrevPageURL(n int) string { if n == 2 { // Previous page is 1; no need for /p/ prefix return "/read" } return fmt.Sprintf("/read/p/%d", n-1) } // handlePostIDRedirect handles a route where a post ID is given and redirects // the user to the canonical post URL. func handlePostIDRedirect(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) postID := vars["post"] p, err := app.db.GetPost(postID, 0) if err != nil { return err } if !p.CollectionID.Valid { // No collection; send to normal URL // NOTE: not handling single user blogs here since this handler is only used for the Reader return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"} } c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64)) if err != nil { return err } c.hostName = app.cfg.App.Host // Retrieve collection information and send user to canonical URL return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String} } func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) error { if !app.cfg.App.LocalTimeline { return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} } updateTimelineCache(app.timeline) feed := &Feed{ Title: app.cfg.App.SiteName + " Reader", Link: &Link{Href: app.cfg.App.Host}, Description: "Read the latest posts from " + app.cfg.App.SiteName + ".", Created: time.Now(), } c := 0 var title, permalink, author string for _, p := range *app.timeline.posts { if c == tlFeedLimit { break } title = p.PlainDisplayTitle() permalink = p.CanonicalURL(app.cfg.App.Host) if p.Collection != nil { author = p.Collection.Title } else { author = "Anonymous" permalink += ".md" } i := &Item{ Id: app.cfg.App.Host + "/read/a/" + p.ID, Title: title, Link: &Link{Href: permalink}, Description: "", Content: applyMarkdown([]byte(p.Content), "", app.cfg), Author: &Author{author, ""}, Created: p.Created, Updated: p.Updated, } feed.Items = append(feed.Items, i) c++ } rss, err := feed.ToRss() if err != nil { return err } fmt.Fprint(w, rss) return nil } diff --git a/routes.go b/routes.go index de19ff2..64c6c0f 100644 --- a/routes.go +++ b/routes.go @@ -1,207 +1,208 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "net/http" "path/filepath" "strings" "github.com/gorilla/mux" "github.com/writeas/go-webfinger" "github.com/writeas/web-core/log" "github.com/writefreely/go-nodeinfo" ) // InitStaticRoutes adds routes for serving static files. // TODO: this should just be a func, not method func (app *App) InitStaticRoutes(r *mux.Router) { // Handle static files fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) app.shttp = http.NewServeMux() app.shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) } // InitRoutes adds dynamic routes for the given mux.Router. func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Create handler handler := NewWFHandler(apper) // Set up routes hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:] if apper.App().cfg.App.SingleUser { hostSubroute = "{domain}" } else { if strings.HasPrefix(hostSubroute, "localhost") { hostSubroute = "localhost" } } if apper.App().cfg.App.SingleUser { log.Info("Adding %s routes (single user)...", hostSubroute) } else { log.Info("Adding %s routes (multi-user)...", hostSubroute) } // Primary app routes write := r.PathPrefix("/").Subrouter() // Federation endpoint configurations wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg}) wf.NoTLSHandler = nil // Federation endpoints // host-meta write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader)) // webfinger write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger))) // nodeinfo niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg) ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db}) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) // Set up dyamic page handlers // Handle auth auth := write.PathPrefix("/api/auth/").Subrouter() if apper.App().cfg.App.OpenRegistration { auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST") } auth.HandleFunc("/login", handler.All(login)).Methods("POST") auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST") auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE") // Handle logged in user sections me := write.PathPrefix("/me").Subrouter() me.HandleFunc("/", handler.Redirect("/me", UserLevelUser)) me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET") me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET") me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET") apiMe := write.PathPrefix("/api/me/").Subrouter() apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET") apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET") apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") // Handle collections write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET") apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET") // Handle posts write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") posts := write.PathPrefix("/api/posts/").Subrouter() posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET") write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET") + write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST") write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST") write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") // Handle special pages first write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) draftEditPrefix := "" if apper.App().cfg.App.SingleUser { draftEditPrefix = "/d" write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") } else { write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") } // All the existing stuff write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET") // Collections if apper.App().cfg.App.SingleUser { RouteCollections(handler, write.PathPrefix("/").Subrouter()) } else { write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader)) write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader)) RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter()) // Posts } write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) return r } func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET") } func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) { r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm)) r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm)) r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm)) } diff --git a/templates.go b/templates.go index 6e9a008..968845d 100644 --- a/templates.go +++ b/templates.go @@ -1,197 +1,201 @@ /* * Copyright © 2018 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( - "github.com/dustin/go-humanize" - "github.com/writeas/web-core/l10n" - "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/config" "html/template" "io" "io/ioutil" "net/http" "os" "path/filepath" "strings" + + "github.com/dustin/go-humanize" + "github.com/writeas/web-core/l10n" + "github.com/writeas/web-core/log" + "github.com/writeas/writefreely/config" ) var ( templates = map[string]*template.Template{} pages = map[string]*template.Template{} userPages = map[string]*template.Template{} funcMap = template.FuncMap{ "largeNumFmt": largeNumFmt, "pluralize": pluralize, "isRTL": isRTL, "isLTR": isLTR, "localstr": localStr, "localhtml": localHTML, "tolower": strings.ToLower, } ) const ( templatesDir = "templates" pagesDir = "pages" ) func showUserPage(w http.ResponseWriter, name string, obj interface{}) { if obj == nil { log.Error("showUserPage: data is nil!") return } if err := userPages[filepath.Join("user", name+".tmpl")].ExecuteTemplate(w, name, obj); err != nil { log.Error("Error parsing %s: %v", name, err) } } func initTemplate(parentDir, name string) { if debugging { log.Info(" " + filepath.Join(parentDir, templatesDir, name+".tmpl")) } files := []string{ filepath.Join(parentDir, templatesDir, name+".tmpl"), filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), } if name == "collection" || name == "collection-tags" || name == "chorus-collection" { // These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl" files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl")) } if name == "chorus-collection" || name == "chorus-collection-post" { files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl")) } if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" { files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl")) } templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...)) } func initPage(parentDir, path, key string) { if debugging { log.Info(" [%s] %s", key, path) } pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles( path, filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), filepath.Join(parentDir, templatesDir, "base.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), )) } func initUserPage(parentDir, path, key string) { if debugging { log.Info(" [%s] %s", key, path) } userPages[key] = template.Must(template.New(key).Funcs(funcMap).ParseFiles( path, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"), filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"), + filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"), )) } // InitTemplates loads all template files from the configured parent dir. func InitTemplates(cfg *config.Config) error { log.Info("Loading templates...") tmplFiles, err := ioutil.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir)) if err != nil { return err } for _, f := range tmplFiles { if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") { parts := strings.Split(f.Name(), ".") key := parts[0] initTemplate(cfg.Server.TemplatesParentDir, key) } } log.Info("Loading pages...") // Initialize all static pages that use the base template filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error { if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") { key := i.Name() initPage(cfg.Server.PagesParentDir, path, key) } return nil }) log.Info("Loading user pages...") // Initialize all user pages that use base templates filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error { if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") { corePath := path if cfg.Server.TemplatesParentDir != "" { corePath = corePath[len(cfg.Server.TemplatesParentDir)+1:] } parts := strings.Split(corePath, string(filepath.Separator)) key := f.Name() if len(parts) > 2 { key = filepath.Join(parts[1], f.Name()) } initUserPage(cfg.Server.TemplatesParentDir, path, key) } return nil }) return nil } // renderPage retrieves the given template and renders it to the given io.Writer. // If something goes wrong, the error is logged and returned. func renderPage(w io.Writer, tmpl string, data interface{}) error { err := pages[tmpl].ExecuteTemplate(w, "base", data) if err != nil { log.Error("%v", err) } return err } func largeNumFmt(n int64) string { return humanize.Comma(n) } func pluralize(singular, plural string, n int64) string { if n == 1 { return singular } return plural } func isRTL(d string) bool { return d == "rtl" } func isLTR(d string) bool { return d == "ltr" || d == "auto" } func localStr(term, lang string) string { s := l10n.Strings(lang)[term] if s == "" { s = l10n.Strings("")[term] } return s } func localHTML(term, lang string) template.HTML { s := l10n.Strings(lang)[term] if s == "" { s = l10n.Strings("")[term] } s = strings.Replace(s, "write.as", "writefreely", 1) return template.HTML(s) } diff --git a/templates/chorus-collection-post.tmpl b/templates/chorus-collection-post.tmpl index 18fb632..a6b6102 100644 --- a/templates/chorus-collection-post.tmpl +++ b/templates/chorus-collection-post.tmpl @@ -1,150 +1,153 @@ {{define "post"}} {{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}} {{if gt .Views 1}} {{end}} {{if gt (len .Images) 0}}{{else}}{{end}} {{range .Images}}{{else}}{{end}} {{if .Collection.StyleSheet}}{{end}} {{if .Collection.RenderMathJax}} {{template "mathjax" . }} {{end}} {{template "highlighting" .}}
{{template "user-navigation" .}} + {{if .Suspended}} + {{template "user-suspended"}} + {{end}}
{{if .IsScheduled}}

Scheduled

{{end}}{{if .Title.String}}

{{.FormattedDisplayTitle}}

{{end}}{{/* TODO: check format: if .Collection.Format.ShowDates*/}}
{{.HTMLContent}}
{{ if .Collection.ShowFooterBranding }} {{ end }} {{if .Collection.CanShowScript}} {{range .Collection.ExternalScripts}}{{end}} {{if .Collection.Script}}{{end}} {{end}} {{end}} diff --git a/templates/chorus-collection.tmpl b/templates/chorus-collection.tmpl index 14d5fbd..504e54f 100644 --- a/templates/chorus-collection.tmpl +++ b/templates/chorus-collection.tmpl @@ -1,230 +1,233 @@ {{define "collection"}} {{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}} {{if gt .CurrentPage 1}}{{end}} {{if lt .CurrentPage .TotalPages}}{{end}} {{if not .IsPrivate}}{{end}} {{if .StyleSheet}}{{end}} {{if .RenderMathJax}} {{template "mathjax" .}} {{end}} {{template "highlighting" . }} {{template "user-navigation" .}} + {{if .Suspended}} + {{template "user-suspended"}} + {{end}}

{{.DisplayTitle}}

{{if .Description}}

{{.Description}}

{{end}} {{/*if not .Public/*}} {{/*end*/}} {{if .PinnedPosts}} {{end}}
{{if .Posts}}
{{else}}
{{end}} {{if .IsWelcome}}

Welcome, {{.Username}}!

This is your new blog.

Start writing, or customize your blog.

Check out our writing guide to see what else you can do, and get in touch anytime with questions or feedback.

{{end}} {{template "posts" .}} {{if gt .TotalPages 1}}{{end}} {{if .Posts}}
{{else}}{{end}} {{if .ShowFooterBranding }} {{ end }} {{if .CanShowScript}} {{range .ExternalScripts}}{{end}} {{if .Script}}{{end}} {{end}} {{end}} diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl index 4af5cb8..e24c6dd 100644 --- a/templates/collection-post.tmpl +++ b/templates/collection-post.tmpl @@ -1,130 +1,133 @@ {{define "post"}} {{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}} {{ if .IsFound }} {{if gt .Views 1}} {{end}} {{if gt (len .Images) 0}}{{else}}{{end}} {{range .Images}}{{else}}{{end}} {{ end }} {{if .Collection.StyleSheet}}{{end}} {{if .Collection.RenderMathJax}} {{template "mathjax" . }} {{end}} {{template "highlighting" .}}

+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}}
{{if .IsScheduled}}

Scheduled

{{end}}{{if .Title.String}}

{{.FormattedDisplayTitle}}

{{end}}
{{.HTMLContent}}
{{ if .Collection.ShowFooterBranding }} {{ end }} {{if .Collection.CanShowScript}} {{range .Collection.ExternalScripts}}{{end}} {{if .Collection.Script}}{{end}} {{end}} {{end}} diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl index ff9c2b9..2618f3d 100644 --- a/templates/collection-tags.tmpl +++ b/templates/collection-tags.tmpl @@ -1,194 +1,197 @@ {{define "collection-tags"}} {{.Tag}} — {{.Collection.DisplayTitle}} {{if not .Collection.IsPrivate}}{{end}} {{if gt .Views 1}} {{end}} {{if .Collection.StyleSheet}}{{end}} {{if .Collection.RenderMathJax}} {{template "mathjax" .}} {{end}} {{template "highlighting" . }}

{{.Collection.DisplayTitle}}

+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}} {{if .Posts}}
{{else}}
{{end}}

{{.Tag}}

{{template "posts" .}} {{if .Posts}}
{{else}}{{end}} {{ if .Collection.ShowFooterBranding }} {{ end }} {{if .CanShowScript}} {{range .ExternalScripts}}{{end}} {{if .Collection.Script}}{{end}} {{end}} {{if .IsOwner}} {{end}} {{end}} diff --git a/templates/collection.tmpl b/templates/collection.tmpl index 34eb076..47dc24d 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -1,230 +1,233 @@ {{define "collection"}} {{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}} {{if gt .CurrentPage 1}}{{end}} {{if lt .CurrentPage .TotalPages}}{{end}} {{if not .IsPrivate}}{{end}} {{if .StyleSheet}}{{end}} {{if .RenderMathJax}} {{template "mathjax" .}} {{end}} {{template "highlighting" . }} {{if or .IsOwner .SingleUser}}{{end}}
+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}}

{{if .Posts}}{{else}}write.as {{end}}{{.DisplayTitle}}

{{if .Description}}

{{.Description}}

{{end}} {{/*if not .Public/*}} {{/*end*/}} {{if .PinnedPosts}} {{end}}
{{if .Posts}}
{{else}}
{{end}} {{if .IsWelcome}}

Welcome, {{.Username}}!

This is your new blog.

Start writing, or customize your blog.

Check out our writing guide to see what else you can do, and get in touch anytime with questions or feedback.

{{end}} {{template "posts" .}} {{if gt .TotalPages 1}}{{end}} {{if .Posts}}
{{else}}{{end}} {{if .ShowFooterBranding }} {{ end }} {{if .CanShowScript}} {{range .ExternalScripts}}{{end}} {{if .Script}}{{end}} {{end}} {{end}} diff --git a/templates/edit-meta.tmpl b/templates/edit-meta.tmpl index 8d96b15..6707e68 100644 --- a/templates/edit-meta.tmpl +++ b/templates/edit-meta.tmpl @@ -1,370 +1,374 @@ {{define "edit-meta"}} Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} — {{.SiteName}}

Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} view post

{{if .Flashes}}{{end}}
{{if .EditCollection}}
{{end}}
UTC now

Date format should be: YYYY-MM-DD HH:MM:SS

 
{{end}} diff --git a/templates/pad.tmpl b/templates/pad.tmpl index ea4246c..a8fca98 100644 --- a/templates/pad.tmpl +++ b/templates/pad.tmpl @@ -1,363 +1,367 @@ {{define "pad"}} {{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}
{{if not .SingleUser}}

{{end}}
{{if .Editing}}{{end}}
{{end}} diff --git a/templates/password-collection.tmpl b/templates/password-collection.tmpl index e0b755d..73c4465 100644 --- a/templates/password-collection.tmpl +++ b/templates/password-collection.tmpl @@ -1,75 +1,78 @@ {{define "password-collection"}} {{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}} {{if .StyleSheet}}{{end}} + {{if .Suspended}} + {{template "user-supsended"}} + {{end}}

{{.DisplayTitle}}

{{if .Flashes}}
    {{range .Flashes}}
  • {{.}}
  • {{end}}
{{else}}

This blog requires a password.

{{end}}

{{if and .Script .CanShowScript}}{{end}} {{end}} diff --git a/templates/post.tmpl b/templates/post.tmpl index dd1375e..52d53a9 100644 --- a/templates/post.tmpl +++ b/templates/post.tmpl @@ -1,98 +1,101 @@ {{define "post"}} {{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}} {{localhtml "title dash" .Language}} {{.SiteName}} {{if .IsCode}} {{end}} {{if gt .Views 1}} {{end}} {{if .Author}}{{end}} {{template "highlighting" .}} -

{{.SiteName}}

+ + {{if .Suspended}} + {{template "user-suspended"}} + {{end}}
{{if .Title}}

{{.Title}}

{{end}}{{ if .IsPlainText }}

{{.Content}}

{{ else }}
{{.HTMLContent}}
{{ end }}
{{if .IsCode}} {{else}} {{if .IsPlainText}}{{end}} {{end}} {{end}} diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl index b59104c..fb69d3a 100644 --- a/templates/user/admin/users.tmpl +++ b/templates/user/admin/users.tmpl @@ -1,31 +1,33 @@ {{define "users"}} {{template "header" .}}
{{template "admin-header" .}}

Users {{.TotalUsers}} total

+ {{range .Users}} + {{end}}
User Joined TypeStatus
{{.Username}} {{.CreatedFriendly}} {{if .IsAdmin}}Admin{{else}}User{{end}}{{if .IsSilenced}}Silenced{{else}}Active{{end}}
{{template "footer" .}} {{end}} diff --git a/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl index 4157fca..7a7446c 100644 --- a/templates/user/admin/view-user.tmpl +++ b/templates/user/admin/view-user.tmpl @@ -1,123 +1,160 @@ {{define "view-user"}} {{template "header" .}}
{{template "admin-header" .}}

{{.User.Username}}

{{if .NewPassword}}

This user's password has been reset to:

They can use this new password to log in to their account. This will only be shown once, so be sure to copy it and send it to them now.

{{if .ClearEmail}}

Their email address is: {{.ClearEmail}}

{{end}}
{{end}} + + + + + + +
No. {{.User.ID}}
Type {{if .User.IsAdmin}}Admin{{else}}User{{end}}
Username {{.User.Username}}
Joined {{.User.CreatedFriendly}}
Total Posts {{.TotalPosts}}
Last Post {{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}
Status + {{if .User.IsSilenced}} +

Silenced

+ + {{else}} +

Active

+ + {{end}} +
Password {{if ne .Username .User.Username}}
{{else}} Change your password {{end}}

Blogs

{{range .Colls}}

{{.Title}}

{{if $.Config.Federation}} {{end}}
Alias {{.Alias}}
Title {{.Title}}
Description {{.Description}}
Visibility {{.FriendlyVisibility}}
Views {{.Views}}
Posts {{.TotalPosts}}
Last Post {{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}
Fediverse Followers {{.Followers}}
{{end}}
{{template "footer" .}} {{end}} diff --git a/templates/user/articles.tmpl b/templates/user/articles.tmpl index 67d3e0b..3edb89c 100644 --- a/templates/user/articles.tmpl +++ b/templates/user/articles.tmpl @@ -1,145 +1,148 @@ {{define "articles"}} {{template "header" .}}
{{if .Flashes}}{{end}} +{{if .Suspended}} + {{template "user-suspended"}} +{{end}}

drafts

{{ if .AnonymousPosts }}
{{ range $el := .AnonymousPosts }}

edit delete {{ if $.Collections }} {{if gt (len $.Collections) 1}}
{{else}} {{range $.Collections}} move to {{.DisplayTitle}} {{end}} {{end}} {{ end }}

{{if .Summary}}

{{.Summary}}

{{end}}
{{end}}
{{ else }}

You haven't saved any drafts yet.

They'll show up here once you do. {{if not .SingleUser}}Find your blog posts from the Blogs page.{{end}}

Start writing

{{ end }}
{{template "footer" .}} {{end}} diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl index 8af3bda..edd06c1 100644 --- a/templates/user/collection.tmpl +++ b/templates/user/collection.tmpl @@ -1,238 +1,241 @@ {{define "upgrade"}}

Upgrade for $40 / year to edit.

{{end}} {{define "collection"}} {{template "header" .}}
+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}}

Customize {{.DisplayTitle}} view blog

{{if .Flashes}}{{end}}

URL

{{if eq .Alias .Username}}

This blog uses your username in its URL{{if .Federation}} and fediverse handle{{end}}. You can change it in your Account Settings.

{{end}}
  • {{.FriendlyHost}}/{{.Alias}}/
  • @{{.Alias}}@{{.FriendlyHost}}

Publicity

  • This blog is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.

  • Only you may read this blog (while you're logged in).

  • A password is required to read this blog.

  • {{if not .SingleUser}}
  • {{if .LocalTimeline}}

    This blog is displayed on the public reader, and is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.

    {{else}}

    The public reader is currently turned off for this community.

    {{end}}
  • {{end}}

Display Format

Customize how your posts display on your page.

  • Dates are shown. Latest posts listed first.

  • No dates shown. Oldest posts first.

  • No dates shown. Latest posts first.

Text Rendering

Customize how plain text renders on your blog.

Custom CSS

See our guide on customization.

View Blog

{{if ne .Alias .Username}}

Delete Blog...

{{end}}
{{template "footer" .}} {{end}} diff --git a/templates/user/collections.tmpl b/templates/user/collections.tmpl index 481fd8f..7f6e83c 100644 --- a/templates/user/collections.tmpl +++ b/templates/user/collections.tmpl @@ -1,111 +1,114 @@ {{define "collections"}} {{template "header" .}}
{{if .Flashes}}{{end}} +{{if .Suspended}} + {{template "user-suspended"}} +{{end}}

blogs

{{if not .NewBlogsDisabled}}

New blog

{{end}}
{{template "foot" .}} {{template "body-end" .}} {{end}} diff --git a/templates/user/include/suspended.tmpl b/templates/user/include/suspended.tmpl new file mode 100644 index 0000000..76906de --- /dev/null +++ b/templates/user/include/suspended.tmpl @@ -0,0 +1,5 @@ +{{define "user-suspended"}} +
+

Your account has been silenced. You can still access all of your posts and blogs, but no one else can currently see them.

+
+{{end}} diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl index fd204a5..d5cc33d 100644 --- a/templates/user/settings.tmpl +++ b/templates/user/settings.tmpl @@ -1,83 +1,86 @@ {{define "settings"}} {{template "header" .}}
+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}}

{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}admin settings{{end}}{{end}}

{{if .Flashes}}{{end}} {{ if .IsLogOut }}

Please add an email address and/or passphrase so you can log in again later.

{{ else }}

Change your account settings here.

Username

{{ end }}

Passphrase

{{if and (not .HasPass) (not .IsLogOut)}}

Add a passphrase to easily log in to your account.

{{end}} {{if .HasPass}}

Current passphrase

New passphrase

{{end}} {{if .IsLogOut}}{{end}}

Email

{{if and (not .Email) (not .IsLogOut)}}

Add your email to get:

  • No-passphrase login
  • Account recovery if you forget your passphrase
{{end}}
{{template "footer" .}} {{end}} diff --git a/templates/user/stats.tmpl b/templates/user/stats.tmpl index f5588fb..705f1e0 100644 --- a/templates/user/stats.tmpl +++ b/templates/user/stats.tmpl @@ -1,53 +1,56 @@ {{define "stats"}} {{template "header" .}}
+ {{if .Suspended}} + {{template "user-suspended"}} + {{end}}

{{if .Collection}}{{.Collection.DisplayTitle}} {{end}}Stats

Stats for all time.

{{if .Federation}}

Fediverse stats

Followers
{{.APFollowers}}
{{end}}

Top {{len .TopPosts}} posts

{{if not .Collection}}{{end}} {{range .TopPosts}} {{ if not $.Collection }}{{ end }} {{end}}
PostBlogTotal Views
{{if ne .Title.String ""}}{{.Title.String}}{{else}}{{.ID}}{{end}}{{if .Collection}}{{.Collection.Title}}{{else}}Draft{{end}}{{.ViewCount}}
{{template "footer" .}} {{end}} diff --git a/users.go b/users.go index d5e9a91..9b5c99c 100644 --- a/users.go +++ b/users.go @@ -1,120 +1,132 @@ /* * Copyright © 2018 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "time" "github.com/guregu/null/zero" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/key" ) +type UserStatus int + +const ( + UserActive = iota + UserSilenced +) + type ( userCredentials struct { Alias string `json:"alias" schema:"alias"` Pass string `json:"pass" schema:"pass"` Email string `json:"email" schema:"email"` Web bool `json:"web" schema:"-"` To string `json:"-" schema:"to"` EmailLogin bool `json:"via_email" schema:"via_email"` } userRegistration struct { userCredentials InviteCode string `json:"invite_code" schema:"invite_code"` Honeypot string `json:"fullname" schema:"fullname"` Normalize bool `json:"normalize" schema:"normalize"` Signup bool `json:"signup" schema:"signup"` } // AuthUser contains information for a newly authenticated user (either // from signing up or logging in). AuthUser struct { AccessToken string `json:"access_token,omitempty"` Password string `json:"password,omitempty"` User *User `json:"user"` // Verbose user data Posts *[]PublicPost `json:"posts,omitempty"` Collections *[]Collection `json:"collections,omitempty"` } // User is a consistent user object in the database and all contexts (auth // and non-auth) in the API. User struct { ID int64 `json:"-"` Username string `json:"username"` HashedPass []byte `json:"-"` HasPass bool `json:"has_pass"` Email zero.String `json:"email"` Created time.Time `json:"created"` + Status UserStatus `json:"status"` clearEmail string `json:"email"` } userMeStats struct { TotalCollections, TotalArticles, CollectionPosts uint64 } ExportUser struct { *User Collections *[]CollectionObj `json:"collections"` AnonymousPosts []PublicPost `json:"posts"` } PublicUser struct { Username string `json:"username"` } ) // EmailClear decrypts and returns the user's email, caching it in the user // object. func (u *User) EmailClear(keys *key.Keychain) string { if u.clearEmail != "" { return u.clearEmail } if u.Email.Valid && u.Email.String != "" { email, err := data.Decrypt(keys.EmailKey, []byte(u.Email.String)) if err != nil { log.Error("Error decrypting user email: %v", err) } else { u.clearEmail = string(email) return u.clearEmail } } return "" } func (u User) CreatedFriendly() string { /* // TODO: accept a locale in this method and use that for the format var loc monday.Locale = monday.LocaleEnUS return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc) */ return u.Created.Format("January 2, 2006, 3:04 PM") } // Cookie strips down an AuthUser to contain only information necessary for // cookies. func (u User) Cookie() *User { u.HashedPass = []byte{} return &u } func (u *User) IsAdmin() bool { // TODO: get this from database return u.ID == 1 } + +func (u *User) IsSilenced() bool { + return u.Status&UserSilenced != 0 +} diff --git a/webfinger.go b/webfinger.go index c95d88e..19116c6 100644 --- a/webfinger.go +++ b/webfinger.go @@ -1,82 +1,91 @@ /* * Copyright © 2018 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( + "net/http" + "github.com/writeas/go-webfinger" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" - "net/http" ) type wfResolver struct { db *datastore cfg *config.Config } var wfUserNotFoundErr = impart.HTTPError{http.StatusNotFound, "User not found."} func (wfr wfResolver) FindUser(username string, host, requestHost string, r []webfinger.Rel) (*webfinger.Resource, error) { var c *Collection var err error if wfr.cfg.App.SingleUser { c, err = wfr.db.GetCollectionByID(1) } else { c, err = wfr.db.GetCollection(username) } if err != nil { log.Error("Unable to get blog: %v", err) return nil, err } + suspended, err := wfr.db.IsUserSuspended(c.OwnerID) + if err != nil { + log.Error("webfinger find user: check is suspended: %v", err) + return nil, err + } + if suspended { + return nil, wfUserNotFoundErr + } c.hostName = wfr.cfg.App.Host if wfr.cfg.App.SingleUser { // Ensure handle matches user-chosen one on single-user blogs if username != c.Alias { log.Info("Username '%s' is not handle '%s'", username, c.Alias) return nil, wfUserNotFoundErr } } // Only return information if site has federation enabled. // TODO: enable two levels of federation? Unlisted or Public on timelines? if !wfr.cfg.App.Federation { return nil, wfUserNotFoundErr } res := webfinger.Resource{ Subject: "acct:" + username + "@" + host, Aliases: []string{ c.CanonicalURL(), c.FederatedAccount(), }, Links: []webfinger.Link{ { HRef: c.CanonicalURL(), Type: "text/html", Rel: "https://webfinger.net/rel/profile-page", }, { HRef: c.FederatedAccount(), Type: "application/activity+json", Rel: "self", }, }, } return &res, nil } func (wfr wfResolver) DummyUser(username string, hostname string, r []webfinger.Rel) (*webfinger.Resource, error) { return nil, wfUserNotFoundErr } func (wfr wfResolver) IsNotFoundError(err error) bool { return err == wfUserNotFoundErr }